← Pylon

Claude Code skill

Drop this file into your Claude Code setup and Claude will know how to build Pylon apps correctly — schema, policies, server functions, React client, deployment. Updates to the skill ship with Pylon.

Install

  1. Copy the skill below.
  2. Save it to ~/.claude/skills/pylon/SKILL.md (user-wide) or .claude/skills/pylon/SKILL.md in your repo (project-scoped).
  3. Restart Claude Code. Claude now loads the skill whenever you work on a Pylon project or ask to build one.

Prefer a one-liner? mkdir -p ~/.claude/skills/pylon && curl -fsSL https://pylonsync.com/pylon-skill.md > ~/.claude/skills/pylon/SKILL.md

SKILL.md·552 lines · 24 KB
---
name: pylon
description: Build realtime apps with Pylon — schema, policies, server functions, React client, and deployment. Use when the user is working in a Pylon project or asks to build with Pylon/Pylonsync.
---

# Pylon — Realtime backend framework

You are helping a developer build an application on **Pylon** (pylonsync.com), a realtime backend framework written in Rust with a TypeScript SDK. Pylon collapses database + API + realtime pub/sub into one process. This skill gives you the shape, conventions, and gotchas needed to build Pylon apps correctly.

## Authoritative references

This skill is a starting point, not the ceiling. When the user asks something this skill doesn't cover — a specific error code, an edge case, a feature not discussed below — fetch the source of truth:

- **Full docs index + concept map:** <https://pylonsync.com/llms.txt> — fetch this first for a condensed overview of every doc page with links.
- **Docs site:** <https://docs.pylonsync.com/> — human docs covering Get started, Core concepts, Auth, Plugins, Clients, Cloud, Operations, and Compare-vs-X pages.
- **Source of truth for APIs:** <https://github.com/pylonsync/pylon/tree/main/packages> — the actual `@pylonsync/sdk`, `@pylonsync/functions`, `@pylonsync/react`, `@pylonsync/react-native`, `@pylonsync/next`, and the Swift SDK at `packages/swift/`. When in doubt about a method name or signature, read the source, not your training data.
- **Working example apps:** <https://github.com/pylonsync/pylon/tree/main/examples> — full apps covering CRM, ERP, chat, 3D, dashboards, plus `examples/swift-todo` for the iOS/macOS SDK.
- **Pylon Cloud:** <https://cloud.pylonsync.com> — managed Pylon. Same binary, same APIs, no infra to run.
- **This skill file (latest):** <https://pylonsync.com/pylon-skill.md> — re-fetch if the user reports the skill is out of date.

**Rule:** if you're about to use an API name or pattern you're not 100% sure exists, fetch the source or docs first. The SDK aliases the common naming variants (see the type table below), but anything outside that table that sounds plausible (`relation(...)`, `v.money()`, `v.enum()`, `v.timestamp()`, `db.useAggregate({sum: ...})`) is probably hallucinated.

## When to use this skill

Use this skill whenever:
- The user's project has a `pylon.manifest.json`, `app.ts` importing from `@pylonsync/*`, or a `functions/` directory next to an `app.ts`.
- The user's Swift project imports `PylonClient`, `PylonSync`, `PylonRealtime`, or `PylonSwiftUI`.
- The user says "Pylon", "Pylonsync", "realtime backend", or asks to build a live-syncing feature.
- The user runs `pylon dev`, `pylon init`, `pylon deploy`, `pylon codegen`, or another `pylon` CLI command.
- The user mentions Pylon Cloud, `cloud.pylonsync.com`, or `pylon deploy --target cloud`.

## Core mental model

A Pylon app is four things, all in one process:

1. **Entities** — typed tables declared in `app.ts` via the `@pylonsync/sdk` DSL. Pylon auto-migrates your database (SQLite by default, or Postgres via `DATABASE_URL`) to match.
2. **Policies** — row-level access rules evaluated as string expressions. Live alongside entities.
3. **Functions** — server TypeScript in `functions/*.ts`. Three flavors: `query`, `mutation`, `action`. RPC-called by the client.
4. **Live queries** — `db.useQuery(...)` in React subscribes to results. Pylon restreams diffs on every relevant mutation.

## Directory convention

```
my-app/
  app.ts                 # schema + policies + manifest — ENTRY POINT
  functions/             # server functions, one per file, default-exported
    createX.ts
    updateY.ts
  client/                # your React components that use @pylonsync/react
  web/                   # Vite (or Next.js) app mounting client/
    package.json
    vite.config.ts
    src/main.tsx
  package.json           # deps: @pylonsync/sdk, @pylonsync/functions
  pylon.manifest.json    # GENERATED — never edit by hand
  pylon.client.ts        # GENERATED — never edit by hand
```

`pylon dev app.ts` watches `app.ts` + `functions/` and regenerates the manifest + typed client on every save.

## Schema (`app.ts`)

Every Pylon app has an `app.ts` that imports from `@pylonsync/sdk`, declares entities + policies, and calls `buildManifest`.

```ts
import { entity, field, policy, buildManifest } from "@pylonsync/sdk";

const User = entity(
  "User",
  {
    email: field.string().unique(),
    name: field.string(),
    createdAt: field.datetime(),
  },
  {
    indexes: [{ name: "by_email", fields: ["email"], unique: true }],
  },
);

const Message = entity(
  "Message",
  {
    roomId: field.id("Room"),
    authorId: field.id("User"),
    body: field.richtext(),
    sentAt: field.datetime(),
  },
  {
    indexes: [
      { name: "by_room_time", fields: ["roomId", "sentAt"], unique: false },
    ],
  },
);

const messagePolicy = policy({
  name: "message_public_read",
  entity: "Message",
  allowRead: "true",
  allowInsert: "auth.userId == data.authorId",
  allowUpdate: "auth.userId == existing.authorId",
  allowDelete: "auth.userId == existing.authorId",
});

const manifest = buildManifest({
  name: "my-app",
  version: "0.1.0",
  entities: [User, Message],
  policies: [messagePolicy],
  queries: [],
  actions: [],
  routes: [],
});

console.log(JSON.stringify(manifest, null, 2));
```

**The last line is required** — `pylon dev` runs `bun run app.ts` and captures stdout as the manifest.

### Field types — EXACT API

```ts
field.string()        // TEXT
field.int()           // INTEGER 64-bit
field.float()         // REAL 64-bit
field.bool()          // 0/1 stored as INTEGER; field.boolean() alias also works
field.datetime()      // ISO-8601 string
field.richtext()      // long-form text
field.id("OtherEntity") // FK to another entity's id column
```

**Modifiers (chainable):**
- `.optional()` — nullable
- `.unique()` — implicit unique index on one column
- `.crdt("text")` — upgrade string/richtext to LoroText for collaborative merge

**Common mistakes to avoid:**
- Both `field.float()` / `field.number()` work (same type). Both `field.bool()` / `field.boolean()` work. Pick whichever reads better.
- `field.id()` without an entity argument **is invalid** — always pass the target entity name.
- Scalar fields are LWW by default. Use `field.richtext()` or `.crdt("text")` when concurrent text edits should merge.

### Indexes

Declare composite indexes in the options block. Live queries use indexed columns for fast fan-out — **index the filter columns you'll query on.**

```ts
{
  indexes: [
    { name: "by_user", fields: ["userId"], unique: false },
    { name: "by_user_created", fields: ["userId", "createdAt"], unique: false },
  ],
}
```

## Policies

Policies are boolean string expressions. They guard direct `/api/entities/*` access. Server functions bypass policies — trust yourself to check inside handlers.

**Bindings available in expressions:**
- `auth.userId` — `string | null`
- `auth.email` — `string | null`
- `auth.roles` — `string[]`
- `data.*` — the proposed row on insert/update
- `existing.*` — the current row on update/delete

**Actions:**
- `allowRead` — applied to query results; unmatched rows are filtered out silently.
- `allowInsert` / `allowUpdate` / `allowDelete` — reject the op with `POLICY_DENIED` if false.
- **Omitted actions default to deny.**

**Operators:**
```
==  !=  <  <=  >  >=  &&  ||  !  +  -  *  /  %
in            // membership: "admin" in auth.roles
ends_with     // string suffix: data.email ends_with "@example.com"
starts_with   // string prefix
```

**Typical patterns:**

```ts
// Public read, author-only write
policy({
  name: "post_public",
  entity: "Post",
  allowRead: "true",
  allowInsert: "auth.userId == data.authorId",
  allowUpdate: "auth.userId == existing.authorId",
  allowDelete: "auth.userId == existing.authorId",
});

// Member-of-org
policy({
  name: "doc_members",
  entity: "Document",
  allowRead: "auth.userId in existing.memberIds",
  allowInsert: "auth.userId in data.memberIds",
  allowUpdate: "auth.userId in existing.memberIds",
});

// Admin-only
policy({
  name: "audit_admin",
  entity: "AuditLog",
  allowRead: "'admin' in auth.roles",
});
```

## Functions (`functions/*.ts`)

Three flavors, all default-exported. The **filename** becomes the RPC name — `functions/createIssue.ts` is callable at `POST /api/fn/createIssue`.

### Validators — EXACT API

Import from `@pylonsync/functions`:

```ts
v.string()
v.int()
v.number() / v.float()     // 64-bit float (both names work)
v.boolean() / v.bool()     // boolean (both names work)
v.datetime()               // ISO-8601 string
v.richtext()               // richtext string
v.id("Entity")
v.optional(v.string())
v.array(v.string())
v.literal("open")    // exact string/number/bool
v.object({ k: v.string() })
```

`v.float()` and `v.number()` are aliases for the same 64-bit float validator. Use whichever matches your `field.*` choice.

### Mutation pattern

```ts
// functions/createIssue.ts
import { mutation, v } from "@pylonsync/functions";

export default mutation({
  args: {
    teamId: v.id("Team"),
    title: v.string(),
    description: v.optional(v.string()),
    priority: v.optional(v.int()),
  },
  // `auth: "user"` is the default — framework rejects anon callers
  // BEFORE the handler runs, so `ctx.auth.userId` is `string`
  // (not nullable) here. Use `auth: "public"` only when intentional.
  async handler(ctx, args) {
    const id = await ctx.db.insert("Issue", {
      teamId: args.teamId,
      title: args.title,
      description: args.description ?? null,
      priority: args.priority ?? 0,
      authorId: ctx.auth.userId,
      createdAt: new Date().toISOString(),
    });

    return { id };
  },
});
```

### Query pattern

```ts
// functions/listIssues.ts
import { query, v } from "@pylonsync/functions";

export default query({
  args: { teamId: v.id("Team") },
  async handler(ctx, args) {
    return ctx.db.query("Issue", { teamId: args.teamId });
  },
});
```

Queries are **live** when called from the React hook — the client subscribes and re-runs on relevant mutations.

### Action pattern (side effects — emails, external HTTP)

```ts
// functions/sendInvite.ts
import { action, v } from "@pylonsync/functions";

export default action({
  // Default `auth: "user"` — anon POSTs to this endpoint never reach
  // the handler. CRITICAL for actions: policies don't gate them, so
  // a forgotten auth check on an action that calls Stripe/Resend/etc.
  // = open vulnerability. `auth: "public"` for webhook receivers only.
  args: { email: v.string(), orgId: v.id("Org") },
  async handler(ctx, args) {
    await fetch("https://api.resend.com/emails", {
      method: "POST",
      headers: { Authorization: `Bearer ${process.env.RESEND_KEY}` },
      body: JSON.stringify({ to: args.email, subject: "Invite" }),
    });
    return { ok: true };
  },
});
```

Actions are **not transactional** — use mutations for atomic multi-row writes.

### `ctx` surface (inside handlers)

```ts
ctx.auth.userId       // string | null
ctx.auth.email        // string | null
ctx.auth.roles        // string[]

ctx.db.insert(entity, data)            // => id
ctx.db.get(entity, id)                 // => row | null
ctx.db.query(entity, filter?)          // => row[]
ctx.db.update(entity, id, patch)       // => void
ctx.db.delete(entity, id)              // => void

ctx.error("CODE", "message")           // throw typed error
ctx.schedule(delayMs, fnName, args)    // enqueue delayed call
```

### Typed errors

Always throw via `ctx.error(code, message)`. Canonical codes:
`UNAUTHENTICATED`, `POLICY_DENIED`, `NOT_FOUND`, `INVALID_ARGS`, `RATE_LIMITED`, `CONFLICT`, `INTERNAL`.

## React client

Wire up the client once, per app. In your Vite/Next entry:

```tsx
// In your app's root component or mount file
import { init, configureClient } from "@pylonsync/react";

const BASE_URL = import.meta.env.VITE_PYLON_URL ?? "http://localhost:4321";
init({ baseUrl: BASE_URL, appName: "my-app" });
configureClient({ baseUrl: BASE_URL, appName: "my-app" });
```

`appName` must match `manifest.name` from `app.ts`.

### Live query

```tsx
import { db } from "@pylonsync/react";

function MessageList({ roomId }: { roomId: string }) {
  const { data: messages, loading } = db.useQuery("Message", { roomId });
  if (loading) return null;
  return (
    <ul>
      {messages.map((m) => <li key={m.id}>{m.body}</li>)}
    </ul>
  );
}
```

Filter keys must be indexed columns for performant fan-out.

### Calling functions

```tsx
import { callFn } from "@pylonsync/react";

async function onSend(roomId: string, body: string) {
  const { id } = await callFn("sendMessage", { roomId, body });
  return id;
}
```

### Session / auth bootstrap (guest fallback pattern)

```tsx
import { storageKey } from "@pylonsync/react";

async function ensureGuest(): Promise<string> {
  const BASE_URL = import.meta.env.VITE_PYLON_URL ?? "http://localhost:4321";
  let token = localStorage.getItem(storageKey("token"));
  let userId = localStorage.getItem(storageKey("user"));
  if (!token || !userId) {
    const res = await fetch(`${BASE_URL}/api/auth/guest`, { method: "POST" });
    const body = await res.json();
    token = body.token;
    userId = body.user_id;
    localStorage.setItem(storageKey("token"), token);
    localStorage.setItem(storageKey("user"), userId);
  }
  return userId;
}
```

## Running the app

```bash
# Terminal 1 — Pylon backend (schema watch + server on :4321)
pylon dev app.ts

# Terminal 2 — web UI
cd web && bun run dev
```

The first `pylon dev` invocation creates `.pylon/dev.db` (SQLite) and runs auto-migration. Set `DATABASE_URL=postgres://...` to target Postgres instead — the adapter is chosen at startup, and all schema/policy/function code is identical either way.

In production, use `pylon start app.ts` instead of `pylon dev`. Same server, no file watcher, blocks on the server thread so a fatal error exits the process and lets the supervisor (systemd / Docker / Fly init) restart cleanly.

## Deployment

Production env vars to set:

```bash
PYLON_DB_PATH=/data/pylon.db
PYLON_FILES_DIR=/data/uploads
PYLON_SESSION_DB=/data/sessions.db
PYLON_CORS_ORIGIN=https://your-web-ui.vercel.app   # EXACT origin — "*" refused in prod
PYLON_DEV_MODE=false
```

Scaffolding:

```bash
pylon deploy                     # default — actual hosted deploy to Pylon Cloud
pylon deploy --target fly        # Dockerfile + fly.toml
pylon deploy --target docker     # Dockerfile
pylon deploy --target compose    # docker-compose.yml + Dockerfile
pylon deploy --target workers    # Cloudflare wrangler.toml (experimental)
pylon deploy --target systemd    # VPS unit file
pylon deploy --target manifest   # just regenerate manifest + client bindings
```

For Fly.io the common pattern is a 1GB volume mounted at `/data` with `auto_stop_machines = "stop"` — idle machines sleep and wake on request.

### CLI ops surface (Pylon Cloud)

Once logged in (`pylon login`, or via the dashboard's "Hand off to your coding agent" card → `pylon login --code XXXX-XXXX`), the CLI covers every dashboard operation. Use these instead of clicking through `cloud.pylonsync.com` for anything scripted.

```bash
pylon projects list                     # all projects you can see
pylon projects use my-app               # set current project for this dir
pylon secrets list / set KEY=v / rm KEY / import .env
pylon logs tail                          # 2s-polling request log
pylon status                             # uptime / requests / jobs / WS clients
pylon deployments list / rollback <id>
pylon domains list / add HOST / verify HOST / rm HOST
pylon db list / backup / restore <id>
pylon data entities / list <E> / get <E> <id>
pylon members list / invite EMAIL [role]
```

Every command accepts `--json` for piping to `jq`. Project context resolves from `--project` flag → `$PYLON_PROJECT` → `.pylon/project` file → interactive picker. The `.pylon/project` file is what `pylon projects use` writes; subsequent commands in that directory tree auto-target.

**Project creation** still lives in the dashboard — provisioning a Fly machine + Postgres DB isn't a one-call CLI operation yet. After signup, point the user at `cloud.pylonsync.com/dashboard` to create the first project; then `pylon projects use <slug>` from the local repo and everything else flows through the CLI.

## Gotchas & rules

- **Type names** — schema (`field.*`) and validator (`v.*`) both accept two naming conventions so you don't have to remember which camp a given API belongs to:

  | Type | Schema (`@pylonsync/sdk`) | Validator (`@pylonsync/functions`) |
  |---|---|---|
  | string | `field.string()` | `v.string()` |
  | integer | `field.int()` | `v.int()` |
  | float | `field.float()` or `field.number()` | `v.float()` or `v.number()` |
  | boolean | `field.bool()` or `field.boolean()` | `v.bool()` or `v.boolean()` |
  | datetime | `field.datetime()` | `v.datetime()` or `v.string()` |
  | richtext | `field.richtext()` | `v.richtext()` or `v.string()` |
  | FK id | `field.id("X")` | `v.id("X")` |

  What still **doesn't exist**: `relation(...)`, `v.money()`, `v.enum()`, `v.timestamp()`. When in doubt, source is at <https://github.com/pylonsync/pylon/tree/main/packages>.
- **Every function file must `export default`** the `mutation()/query()/action()` result. Named exports are ignored.
- **`functions/*.ts` file names are the RPC names.** `functions/create-issue.ts` would be called as `create-issue` — prefer camelCase to match JS identifier conventions.
- **Generated files** (`pylon.manifest.json`, `pylon.client.ts`) are rebuilt on every `pylon dev` invocation. Never edit by hand.
- **Workspace deps** in examples use `workspace:*` — if you scaffold outside the Pylon monorepo, replace with the published version.
- **Dev mode is generous by default** (CORS `*`, rate limits raised). Production requires explicit `PYLON_CORS_ORIGIN` — `*` is rejected.
- **Policies filter silently on read** but throw `POLICY_DENIED` on write. If a list query returns fewer rows than expected, check read policies.
- **Live queries need indexes** on filter columns. A `useQuery("Message", { roomId })` with no index on `roomId` will still work but scale O(N) per change.
- **Always call `ctx.error(code, msg)`** instead of throwing plain `Error` — plain errors become generic `HANDLER_ERROR` on the client with the real message stripped.

## Quick decision guide

| User wants | You write |
|---|---|
| A new table | New `entity(...)` in `app.ts` + matching `policy(...)` + `buildManifest({ entities: [...], policies: [...] })` |
| A list in the UI | `db.useQuery("Entity", { filter })` — make sure `filter` keys are indexed |
| A form submission / write | A `mutation()` in `functions/X.ts` + `await callFn("X", args)` in the component |
| Auth-gated functions | `auth: "user"` is the default on every `query` / `mutation` / `action`. Anon callers get `401 AUTH_REQUIRED` before the handler runs. `auth: "public"` to opt out (webhooks, healthchecks). CRITICAL on actions — policies don't gate them. |
| Access rules | `policy({ allowRead: "...", allowInsert: "..." })` — not middleware, not function guards |
| Email / external API | `action()` (not `mutation()`) |
| A scheduled job | `ctx.schedule(delayMs, "fnName", args)` inside a mutation |
| Deploy | `pylon deploy --target fly` then `fly deploy . --config fly.toml` |

## Before you finish a task

- Run `bun run app.ts` in the project root — if it errors, the manifest won't build and `pylon dev` will fail silently on function load.
- If you added a function, verify it's discoverable by opening the project and checking that `pylon dev` logs list your new function name in the `Loaded N functions` output.
- If you changed an entity, schema auto-migration runs — but destructive changes (dropping a required column) will refuse to apply without bumping `manifest.version`.

## Beyond the React quickstart — what's available

This skill focused on the React/TS happy path. Pylon has more — fetch the docs page when these come up:

### Auth (`/auth/*` in the docs)
- **Magic codes** (`/api/auth/magic/send` + `/verify`) — recommended sign-in flow. 6-digit, 10-min expiry, throttled.
- **Email + password** (`/api/auth/password/register` + `/login`) — Argon2id-hashed.
- **OAuth** — Google + GitHub built in (`/api/auth/login/:provider` + `/callback/:provider`). CSRF-protected via state tokens.
- **Sessions** — opaque 256-bit tokens, 30-day default. `/api/auth/refresh`, `/sessions` GET/DELETE for management.
- **Trusted server-side mint** — `POST /api/auth/sessions/trusted-mint`. HMAC-signed (`X-Pylon-Trusted-Signature: hex(HMAC_SHA256(PYLON_TRUSTED_SECRET, ts + "." + body))`), ±5min freshness window. Reach for this when another trusted system (Stripe Checkout, custom IdP) has verified the email and you want to skip the magic-link roundtrip. Opt-in: 404 unless `PYLON_TRUSTED_SECRET` is set.
- **RBAC** — roles on the session; `auth.hasRole('x')` in policies. `admin` role bypasses everything.
- **Multi-tenant** — `auth.tenantId` from `/api/auth/select-org`; row-scoped policies via `data.orgId == auth.tenantId`.
- **API keys** — via the `api_keys` plugin, scoped + rotatable + Argon2-hashed.

### Plugins (`/plugins/*` in the docs)
32 built-ins, declared in `manifest.plugins`:
- **Security**: `rate_limit`, `cors`, `csrf`, `net_guard` (SSRF defense), `totp`, `jwt`, `api_keys`, `session_expiry`, `password_auth`
- **Data hygiene**: `validation`, `slugify`, `timestamps`, `computed`, `cascade`, `versioning`, `soft_delete`, `tenant_scope`, `organizations`
- **Search & AI**: `search` (FTS5 + facets), `vector_search`, `ai_proxy`, `mcp` (Model Context Protocol server)
- **Integrations**: `file_storage` (S3/R2/Stack0), `cache`, `cache_client` (Redis), `email`, `webhooks`, `stripe`, `feature_flags`, `audit_log`

### Clients (`/clients/*` in the docs)
- `@pylonsync/sdk` — schema DSL + manifest builder
- `@pylonsync/react` — hooks (covered in this skill)
- `@pylonsync/react-native` — Expo SQLite-backed offline replica
- `@pylonsync/next` — Server Actions, RSC data fetching, middleware auth
- `@pylonsync/sync` — sync engine standalone (Vue, Svelte, Solid, vanilla)
- `@pylonsync/loro` — Loro CRDT integration for collaborative editing
- **Swift SDK** at `packages/swift/` — `PylonClient`, `PylonSync`, `PylonRealtime`, `PylonSwiftUI`. iOS 16+, macOS 13+, tvOS 16+, watchOS 9+, Linux. Codegen via `pylon codegen client --target swift`.

### Pylon Cloud
Managed Pylon at `cloud.pylonsync.com`. Same binary, same APIs.
- `pylon login` then `pylon deploy --target cloud`
- Custom domains via `pylon domain add`
- Environment vars via `pylon env set/list/unset`
- Includes: managed Postgres, TLS, magic-link email, OAuth (your creds), file storage, Studio, logs/metrics
- Pricing: usage-based, no monthly minimums; free tier covers small projects

### Compare-vs-X pages
If the user asks "Pylon vs Convex/Supabase/Firebase/Colyseus/Playroom/Nakama", point them at `/compare/<vendor>` in the docs — each page has a structured comparison with sources.

## Swift / iOS / macOS specifics

When the user is in a Swift project (Xcode, `Package.swift`, `*.swift` files):

- **Install** via SPM: `.package(url: "https://github.com/pylonsync/pylon-swift.git", from: "0.3.0")`
- **Auth**: `try await client.startMagicCode(email:)` then `try await client.verifyMagicCode(email:code:)`
- **Sync**: `await SyncEngine(config: cfg, client: client, persistence: SQLitePersistence(...))`, then `await engine.start()`
- **Mutations**: `await engine.insert("Todo", ["title": .string("x")])` (optimistic, queued, idempotent)
- **SwiftUI**: `@StateObject var todos = PylonQuery<Todo>(engine: engine, entity: "Todo")` — `todos.rows` re-renders on change
- **CRDTs**: `PylonLoroDoc(entity:rowId:)` then `await crdtDoc.attach(to: engine)` — uses `loro-swift` internally
- **Codegen**: `pylon codegen client manifest.json --target swift --out PylonGenerated.swift` produces typed structs + `PylonClient` extensions
- **Linux**: works via `FoundationNetworking`; needs `apt-get install libsqlite3-dev`

The Swift SDK is at full TS-sync parity — same wire format, same crash-safety, same offline behavior. CRDT logic is shared with TS via the same Rust Loro core.

Reference example: `examples/swift-todo/` is a complete SwiftUI iOS/macOS app.