---
name: "terminus-local-development"
description: "Build, run, test and push something to Terminus — an app, agent, service or skill — with the terminus CLI and @terminus-ai/app-sdk. Use when working in a directory holding terminus.json or SKILL.md, when a task mentions terminus clone/push/dev or @terminus-ai/app-sdk, or when asked to make something that runs on Terminus."
---

# Building on Terminus

You are working on something that runs on Terminus. Read this before touching
the package: it is the loop, the commands and what they print, the app SDK,
and the traps that otherwise cost an afternoon. Every command and call here is
real. For one that is not here, `terminus help <command>` is the truth for the
CLI, and each docs page is also plain Markdown you can fetch:
https://www.terminus.build/docs/md/index.md lists them (`apps.md`, `sdk.md`,
`agents.md`, `skills.md`, `services.md`, `cli.md`).

Terminus has four kinds of creation, each at an address `@handle/slug`:

| Kind | The folder holds | It runs |
|---|---|---|
| app | a browser bundle, `schema.ts` and `terminus.json`, and optionally `server/` | in a window on the Terminus desktop; Terminus supplies the person, their data, the people they share it with, services and payments, and runs the app's server |
| agent | `AGENT.md`, `terminus.json`, files it reads | on Terminus, for each person who installs it |
| skill | `SKILL.md`, `terminus.json` and the files SKILL.md refers to | inside agents that load it |
| service | `terminus.json`, `README.md`, and its code (hosted) or `openapi.json` (external) | hosted on Terminus as WebAssembly, or on your own server; Terminus calls it for apps, agents and other services |

## Ground rules

1. **A creation is made on the web first.** Creations → New creation
   (https://www.terminus.build/os?open=create) gives it its address. The CLI
   never creates one. No address yet? Ask the person to make it, and say which
   kind.
2. **`terminus push` only updates the draft**, which only the maker and their
   collaborators see. Publishing — the release people get, its version,
   visibility and price — is done by a person on the creation's page on the
   web. There is no `terminus publish`; never invent one.
3. **Nobody hands you a credential.** There are no API keys. The CLI signs in
   through a browser; app code never sees a password or a token. When the CLI
   says you are not signed in, ask the person to run `terminus login`. A
   secret an app's server or a service reads is set by the person, on the web
   or with `terminus secrets set` — never written into a file by you.
4. **`terminus.json` says what the package is** — `kind`, `id`, `version` —
   and little else. What an app uses is declared in its `schema.ts`, and what
   each server operation reaches is said on the operation
   ([Permissions come from your code](#permissions-come-from-your-code)).
   Never add fields to `terminus.json` to grant something.
5. **Parse `--json`, branch on exit codes.** Most commands take `--json`. Exit
   codes: `0` ok, `1` error, `2` bad usage, `3` not signed in, `69` Terminus
   unreachable, `75` rate limited, `77` not allowed. With `--json` a failure is
   one object on stderr, `{"error":{"code":…,"message":…}}`, plus `status`,
   `api_code` and `details` when Terminus refused the request.

## The loop

### 1. Sign in

```bash
npm install -g @terminus-ai/cli      # Node.js 22.16 or newer
terminus account                     # "Hi, Name (@handle)"; exits 3 when signed out
terminus login                       # opens a browser; the person approves this computer
terminus login --no-browser          # prints the link for the person to open on this computer
```

A session lasts seven days. In a container or CI the person can set
`TERMINUS_TOKEN` to a session token instead; it wins over the saved login.

- When `terminus account` exits 3: stop and ask the person to sign in. You
  cannot approve a sign-in yourself.

### 2. Get the package into a folder

```bash
terminus clone @handle/my-app              # a fresh folder, linked to the creation
terminus clone @handle/my-app --template react   # an empty app: vanilla (default), react, svelte
terminus remote add @handle/my-app         # link the folder you are in; uploads nothing
terminus init app my-app                   # scaffold from scratch: app, agent, service, skill
terminus fork @someone/their-app           # your own copy of someone's forkable creation
```

- **An address and no folder yet** → `terminus clone`. It refuses a folder that
  is not empty. An empty creation arrives started from its kind's template,
  ready to run. It prints the folder, the draft's revision (`draft r3`) and
  the creation's page.
- **Code already in this folder** → `terminus remote add <address>`. It writes
  the address into `terminus.json` (a skill's too), then
  `terminus status` says how the folder and the draft differ.
- **No creation yet** → `terminus init <kind> [<dir>]` scaffolds one, and
  prints the next steps; once the person has made the creation on the web,
  `terminus remote add <address>`. In a folder cloned from an empty creation,
  `terminus init <kind> .` fills it in and keeps its link.
- **Someone else's work** → `terminus clone` gives a read-only copy of its
  latest release (`terminus pull` brings newer ones; it never pushes).
  `terminus fork` makes your own draft at `@you/<slug>` and clones it. Only
  open-source apps and agents fork, and free skills that aren't restricted; a
  fork with a price is bought on the web — the CLI prints the price and the
  page, and stops.

### 3. Run it on this machine

`terminus dev` runs whatever the folder holds: an app on local test data, its
server included, an agent in a chat page, a service's test page.
[Apps](#run-an-app-locally), [agents](#agents) and [services](#services) each
have their own part below.

### 4. Check it, and push it

```bash
terminus validate                  # what Terminus checks when you push; works offline
terminus status                    # what changed here, and whether the draft changed since
terminus diff                      # this folder against the draft, line by line: what a push would change
terminus log                       # the creation's releases, newest first
terminus pull                      # bring in what the draft gained (the web editor, a collaborator)
terminus push                      # replace the draft with this folder
```

`terminus status` prints the address and draft revision, whether the draft
changed since the folder last matched it, the files changed here ("Changes not
pushed"), `Build: stale` when the source moved past the last build, and
`Live:` — the published version, or "not published yet".

`terminus push` builds an app first — its `npm run build`, and its server when
it has one — replaces the draft with this folder, and prints:

```
Pushed @handle/my-app to its draft (r4): 1 source file and 1 build file changed; 2.1 KB uploaded.
  publish   https://www.terminus.build/os?open=creation:…
```

Or `Everything up to date` when nothing changed. A draft keeps no history:
whatever it held is gone after a push.

- **Status says the draft changed since this folder last matched it** → a
  save on the web, or a collaborator's push. `terminus pull` first, or the
  push replaces it (push says so when it did). Pull goes file by file: a file
  only the draft changed is taken, one only you changed is kept, and one both
  changed stops the pull before anything is written, naming it. Then ask the
  person which to keep: `terminus pull --force` takes the draft's,
  `terminus push` sends yours.
- **Push says `bumped to v0.0.3 in terminus.json`** → that version was
  already released and the draft holds something new, so push wrote the next
  patch for you. Commit it with your work. If the change deserves a minor or a
  major version, set that in `terminus.json` and push again.
- **Push warns that `terminus.json` names a version that is already released**
  → set `"version"` to the one it names (the next patch) before anyone
  publishes. (An older CLI warns instead of bumping.) The version in
  `terminus.json` is the one the next release will get.
- **You need to undo a pushed change** → fix it in the folder and push
  again. A draft keeps no history to go back to; the folder's own Git does.

### 5. Try the pushed draft on Terminus: Test

`terminus dev` is the fast loop; Test is the real thing. On the app's page in
Creations (the link push prints), **Test** asks how many people — Just me, Two
people, or any number up to eight — and opens a window for each: Alan, Bob,
Carol and on down the alphabet, each running the pushed draft on Terminus as a
different test person, with every SDK call answered as it will be once the app
is published. Only the person can press Test; tell them what to try.

- A window runs the build the last push sent. Push again and open windows move
  to the new build by themselves. **Stale build** means the source moved past
  that build: push. **Behind the draft**: press Run again.
- Handles there name test people only: `bob` is the test person Bob, and he
  exists once his window has opened — invite him before that and the call
  answers `not_found`.
- An invitation or notification for a test person lands in the maker's
  Notification Center, where the maker answers it for them. What test people
  send to the maintainers (`maintainers.send`) lands in the app's Inbox under
  Test submissions, with no consent asked.
- The maker pays for what the windows spend. Each day (UTC) they get 5 credits
  and 200 background runs (jobs, and what schedules, webhooks and watches
  start). Past the credits, calls answer `quota_exceeded`; past the runs,
  `jobs.run` answers `rate_limited`. The Test panel shows today's use.
- A connector service runs in the maker's own account there, whichever test
  person calls it. A Test window does not ask to connect it: until the maker
  has, its calls answer `not_connected`.
- A service job that takes one of the person's files (a document to convert,
  say) is refused with `forbidden` there.
- A window's sign-in lasts 12 hours; after that, and after Forget, its calls
  answer `session_ended` — press Run again.
- What test people write is kept between runs until the maker chooses
  **Forget test data…** under Test, which deletes them and everything they
  wrote and starts every open window over.

### 6. Publish: the person, on the web

Publishing happens on the creation's page: they choose the version
(`MAJOR.MINOR.PATCH`, starting at `0.0.1`, always above the live release) and
write a note. Name, icon, description, price, who can see it and (for an app
or agent) open source are set there too, and change without a new release. So
are an app's secrets, its daily budget and — once a release that declares them
is published — its products' prices. Your job ends at `terminus push` and
telling them it is ready, and what they still need to set.

Once something is published: `terminus logs @handle/my-app` shows an app's
log (`--level error`), `terminus outdated @handle/my-app` says which of
the services, skills and agents its release names have newer releases, and
`terminus log @handle/my-app` lists its releases.

## Apps

### What is in an app folder

| File | What it is |
|---|---|
| `terminus.json` | `{ "kind": "app", "id": "@handle/my-app", "version": "0.0.1" }` — plus `build`, `server` or `migrations` only when that folder is not `dist/`, `server/` or `migrations/`, and `schema` only when that file is not `schema.ts`, `.js` or `.mjs` at the root; any other key is refused |
| `index.html`, `src/` | the app; anything that builds to static files works (templates use Vite) |
| `vite.config.js` | keeps `base: "./"` and a proxy that lets `npm run dev` reach `terminus dev` on 8868 — keep both |
| `AGENTS.md` | notes for coding agents: where the app keeps its data |
| `dist/` | the build: what runs, locally and published — what `npm run build` writes, or the finished files themselves when package.json has no build script; a web manifest in it says which files the app opens ([Fit into the desktop](#fit-into-the-desktop)) |
| `schema.ts` | what the app is built on, declared once: its collections, services, products and channels, and what agents may use (or `.js`, `.mjs`; `"schema"` in `terminus.json` names another file) ([The schema](#the-schema), [Tools for agents](#tools-for-agents)) |
| `migrations/` | optional: how a release reshapes data earlier releases wrote (`"migrations"` in `terminus.json` names another folder) ([Migrations](#migrations)) |
| `server/` | optional: the app's server, `server/index.ts`, for code the browser cannot run; Terminus runs it ([The app's server](#the-apps-server)) |
| `.env.local` | secrets for `terminus dev`; never uploaded |

There is no server of your own to run and no database of yours: Terminus
keeps the data and runs `server/`. Do not add either.

### An app window is small

An app opens in a desktop window: 640×460 by default, and people can shrink it
to 400×300. That window's content box is the app's whole viewport, so
`@media` queries describe the window, not the screen. Design the narrow case
first: a master–detail app keeps both panes at every width (narrow the list,
fold it to a rail of icons below ~600px) rather than giving one pane the whole
window, and keep fixed toolbars short — a 300px-tall window has little room.

### Run an app locally

```bash
npm install
npm run build        # REQUIRED: terminus dev serves the built dist/
terminus dev         # http://localhost:8868/, as Alan
```

It prints one URL per person:

```
terminus dev — @handle/my-app
Data space: /…/my-app/.terminus/dev
  @alan: http://localhost:8868/
```

`terminus dev` serves your build with the same SDK calls, answers and error
codes as Terminus, on local test data in `.terminus/dev/` (never uploaded) that
survives restarts. Nothing there touches anyone's real data.

- **To see a change**: `npm run build` again and reload, or keep `terminus dev`
  running and start `npm run dev` beside it for Vite's hot reload — the
  template's proxy sends the SDK's calls to `terminus dev`.
- **Anything people do together** — a chat, a shared board, invitations,
  presence — `terminus dev --members 2` (or 3). Each person gets a port of
  their own (8868, 8869, …), a name (Alan, Bob, Carol, …, up to 26) and their
  own data, and each sees what the others do as it happens: open two URLs side
  by side and watch a message or an edit cross.
- **Named people** — `--members alice,bob`; **names, bios and avatars from a
  file** — `--profiles people.json` (`{ "alice": { "name": "Alice Chen",
  "bio": "…", "avatar": "./alice.png" } }`).
- **A guest** — `--guest` adds a signed-out port after the people's, as a
  visitor of an app open to guests meets it.
- **Start from empty data** — `--fresh`.
- **8868 is taken** — `--port 5000`; with `--members` the others follow on
  5001, 5002…. Point the Vite proxy's target at the new port too. A busy port
  is reported with who holds it and a free range to use instead.
- **The app's server** runs here too: `terminus dev` builds `server/` as a
  push does and runs each call in a fresh copy — as the person
  whose tab called, as the guest on the `--guest` port, or as the app for its
  own schedules. A schedule does not wait for its time: run it from the dev
  page, or with
  `POST http://localhost:8868/__terminus_dev/schedules/<operation>/run`.
  Secrets come from `.env.local`. App-wide collections and the `"server"` and
  `{ owns }` rules work in the local data as they do on Terminus.
- **Declared services** reach the real ones, as you — so the CLI must be
  signed in (`terminus login`) — and are billed to you, at most 200 service
  calls a day. A connector service works in your own account.
- **Products** answer with test prices, set on the dev page (1 credit unless
  changed), and `buy()` opens a test purchase sheet — Buy or Cancel — that
  marks the product owned for that person, or that space. Nothing is charged.
- **The desktop's parts**: each person's page shows what the desktop would —
  the window title and Dock badge the app sets, a stand-in for the share
  sheet, and Open With for the file types its manifest takes.
- **A hosted service you are writing** (its folder holds its code) → run
  it beside the app instead of calling the real one:
  `terminus dev --service ../my-service` (needs a CLI newer than 0.0.4;
  repeat the option for more services). The folder's `terminus.json` `id`
  says which service it is; without one, name it:
  `--service @you/my-service=../my-service`. The app's calls to it answer
  as they do on Terminus, with nothing billed; it restarts when its folder
  changes, and each call reaches only what its operation declares (its
  secrets from the service's `.env.local`). To test how the app handles
  a failing service, `POST http://localhost:8868/__terminus_dev/services/faults`
  with `{"code": "service_unavailable", "times": 2}` makes the next calls
  answer that code; `GET …/__terminus_dev/services/calls` lists what each
  call answered.
- **The app's log** prints in the terminal running `terminus dev`, entry for
  entry as Terminus keeps it: `log.info` and the other log calls, what the
  window did not catch, and each server run with what it wrote.
- `terminus dev --remote` keeps your local build but sends every SDK call to
  your real account on Terminus — real data, real quotas. It needs a published
  release you have installed; `--members`, `--profiles` and `--fresh` do not
  apply.

What each person's page shows besides your app: a DEV card at the top right
for each invitation (Accept, Decline — how that person consents) and each new
notification, the way the desktop's Notification Center would. For scripted
tests, the same invitations are at
`GET http://localhost:<their port>/__terminus_dev/requests`, and
`POST http://localhost:<their port>/__terminus_dev/requests/<id>/accept` (or
`/decline`) answers one as that person.

`terminus data` handles the local data as the SQLite files people download
(see [Migrations](#migrations)):

```bash
terminus data export --member bob bob.sqlite          # Bob's local data as his data.sqlite
terminus data export --space <space id> launch.sqlite  # one space's file
terminus data import --member bob data.sqlite         # start Bob from a file, one downloaded from the desktop too
terminus data inspect --member bob                    # what Bob's local data holds
terminus data migrate --check data.sqlite             # rehearse your migrations on a copy
```

`import` replaces existing data only with `--force` (a backup is kept).

### Permissions come from your code

`terminus.json` grants nothing. What a release may reach is what its code
declares, and Terminus refuses the rest:

| Declared in | The release may |
|---|---|
| `schema.ts`, `collections` | keep records in those collections, and no others (`collection_undeclared`) |
| `schema.ts`, `services` | call those services, on any of their operations — a connector service with the person's own account |
| `schema.ts`, `products` | sell those products |
| `schema.ts`, `channels` | send the maintainers what people submit, on those channels |
| a server operation's `network` | fetch from those hosts, in that operation |
| a server operation's `secrets` | read those secrets, in that operation |

- Notifications need nothing: `notifications.create` is always allowed, and
  each person decides whether an app's notifications reach them.
- A collection's `permissions` decide who may read and write its records;
  see [Records](#records).

### The SDK

```js
import { ready, spaces } from "@terminus-ai/app-sdk";
import app from "../schema";   // the app's handles: see The schema
```

Importing does nothing until the first call. A page without a bundler imports
`@terminus-ai/app-sdk/auto` and uses `window.TerminusSDK`. Everything the SDK
returns is camelCase; what you or others wrote — record values, `meta`,
payloads, a service's answer — comes back exactly as written.

#### Who is here: `ready()`

```js
const info = await ready();
if (info.guest) {
  showSignIn();              // a guest (not signed in; user is null)
} else {
  greet(info.user.displayName);   // { id, handle, displayName, avatarUrl, publicProfileUrl }
}
// also: info.appId, info.name, info.iconUrl, info.installationId, info.releaseId,
// info.releaseVersion, info.grants
```

- **Guests.** When the app is open to guests (a setting beside its visibility
  and price), someone who is not signed in gets `guest: true`. Their own
  records (personal collections), files and storage buckets without a space
  work and stay on their device, and they may call the server operations
  marked `guests: true` — which is how a guest reaches app-wide data. Every
  other call that needs an account — spaces, rooms, people, notifications,
  services, products — throws a `TerminusError` with code
  `guest`. Keep the app usable, and where a feature needs an account call
  `session.signIn({ returnTo: "/where/they/were" })`: they sign in (or sign
  up) and come back there signed in, with what the app kept in the browser,
  and their guest records, files and objects move into their account.
  Try it with `terminus dev --guest`, which adds a signed-out port.
- **The session can end.** When the person signs out of Terminus or the
  session expires, calls answer `session_ended`. Stop and say so; opening the
  app again signs them back in. `session.signOut()` signs out of this app on
  this device: that window then gets `session_ended`, the app's other windows
  `unauthorized`. Both are final — never retry them.
- Key anything you keep on the device by `info.user.id`, `info.installationId`
  and `info.releaseId`, so an update never reads another release's copy.

#### Errors

Every refusal is a `TerminusError`: `status` (0 when Terminus was never
reached), `code`, `details`, `message`. Branch on `code`, never the message.

```js
import { TerminusError, isRetryable } from "@terminus-ai/app-sdk";

try {
  await app.tasks.put(id, value, { expectedVersion: record.version });
} catch (error) {
  if (error instanceof TerminusError && error.code === "version_conflict") return reloadAndAsk();
  if (error instanceof TerminusError && error.code === "session_ended") return showSignedOut();
  if (isRetryable(error)) return retryLaterWithTheSameKey();
  throw error;
}
```

`isRetryable(error)` is true for `network`, `still_pending` (the same key is
still being worked on), `rate_limited` and other 408/425/429 answers, and 5xx
answers — `space_migrating` too (a space's data is moving to your newer
version; the SDK already waits and retries it) — except `not_implemented` and
`unsupported_in_dev` (`terminus dev` cannot do this on your machine; try it in
Test). Never retry `session_ended`, `unauthorized`, `guest`, or any other
4xx: `forbidden` (the role, a block, a collection's rules, or something the
release does not declare), `collection_undeclared` (a collection
`schema.ts` does not declare), `grant_required` (a one-time consent is
missing), `not_found`, `conflict` (a unique field or a relation),
`update_required` (a newer version's migration moved that data on: this
version reads it but cannot write it; the desktop offers the update and the
outbox keeps its entries), `version_conflict` (read again, then decide),
`idempotency_conflict` (a key reused for a different request),
`unprocessable` (a `validate` rule; its message comes with it),
`refused` (the app's own server refused: its code and status are in
`details`), `cancelled` and `not_available` (a product's `buy()`: the
sheet was closed, or there is no price yet), `payload_too_large`,
`quota_exceeded` (credits or storage).

#### The schema

Everything an app is built on is declared once, in its schema — `schema.ts`
(or `.js`, `.mjs`) at the root of the project, or the file `terminus.json`
names with `"schema": "src/schema.ts"`, which must exist. Its default export
is `schema({ … })`:

```js
// schema.ts
import { schema } from "@terminus-ai/app-sdk";

export default schema({
  collections: {
    tasks: {
      ownership: "personal",
      indexes: ["status"],
      search: {
        fields: ["title"],   // what tasks.search reads
        // In the desktop's search too: titled by a field, opened at a route ({id} is the record's).
        desk: { title: "title", subtitle: "status", route: "/tasks/{id}" },
      },
    },
    cards: { ownership: "space" },                // one set per space: app.cards.in(spaceId)
    scores: { ownership: "app" },                 // one set for everyone; only the server writes
  },
  services: {
    web: "@terminus/web",                         // by address, never with a version
    gmail: "@acme/gmail",                         // a connector: the person's own Gmail
    calendar: { address: "@acme/calendar", reads: ["tasks"], writes: [] },
  },
  products: { pro: { kind: "subscription" } },    // or "one_time"; priced on the web
  channels: { feedback: {} },                     // maintainers.send("feedback", …); no handle
});
```

```js
import app from "../schema";   // in src/ and server/ alike
await app.tasks.put("t1", { title: "Ship", status: "open" });
const page = await app.web.call("fetch", { url });
```

- Each declared name is one handle: `app.tasks` (a collection),
  `app.web` (a service), `app.pro` (a product). A channel makes none. A name is 1–64 lowercase letters, digits,
  `-` or `_`, starting with a letter, and no two sections share one.
- `terminus push` runs the file on its own — it may build declarations in
  code, but reads no files and reaches no network — and keeps what it declares
  with the release, a closed set: anything undeclared is refused, and
  `terminus dev` holds the app to the same. A declaration is fixed per
  release; change it in the next, additively.
- At most 32 collections, 32 services, 32 products and 8 channels per
  release.
- What agents may use is declared here too — a collection's `agents` word
  and an `agents` section — and makes no handle: [Tools for agents](#tools-for-agents).

#### Records

A collection is a named set of JSON records. Its handle reads and writes it;
`.in(spaceId)` is the same collection in one space.

```js
const tasks = app.tasks;

await tasks.put("t1", { title: "Ship", status: "open" });              // create or replace
await tasks.put("t2", draft, { expectedVersion: 0 });                  // only if t2 does not exist yet
const record = await tasks.getRecord("t1");                            // { id, value, version, updatedAt, … }
await tasks.put("t1", next, { expectedVersion: record.version });      // only if nobody wrote since
await tasks.update("t1", (task) => ({ ...task, status: "done" }));     // read, change, write; retries on a race
await tasks.delete("t1");

const { records: open } = await tasks.find({ status: "open" }, { sort: "-priority", limit: 20 });
const { records, nextCursor } = await tasks.find({ due: { gte: "2026-09-01", lt: "2026-10-01" } });
for await (const task of tasks.scan({ status: "done" })) archive(task);   // every match, page by page
const { records: hits } = await tasks.search("launch -draft");          // web-search syntax over search.fields

const result = await tasks.putMany(rows);   // many, 64 per commit, in order; safe to call again
if (result.error?.retryable) retryLater(result.unstored);

await app.cards.in(spaceId).put("c1", { title: "Launch" });            // a space's collection, in one space
```

- Record ids: 1–128 letters, digits, `.`, `-`, `_`, starting with a letter or
  digit. A value is at most 128 KiB as JSON.
- `where` matches top-level fields: a plain value matches by equality (an
  array or object value by containment), and `{ eq, ne, gt, gte, lt, lte, in,
  exists }` are operators — at most 8 conditions, `in` with 1–32 values. A
  field name is one key (`"a.b"` is the key named `a.b`, not a path).
- A page is at most 200 records. `find` and `search` answer one page,
  `{ records, nextCursor, hasMore }`; unsorted queries page by id — pass
  `nextCursor` back as `after` — and `scan` walks every page. A sorted query answers one
  page (`limit` ≤ 200) and cannot page on with `after`.
- A blank `search` (empty, or only spaces) is no search: pass a search box's
  text straight in.
- A query Terminus would refuse — a bad field name, `in` without 1–32
  values, more than 8 conditions, a bad `sort` — throws `WhereGrammarError`
  before anything is sent. `{ tags: ["urgent"] }` matches records whose
  `tags` array holds `"urgent"`.
- **Ownership.** `"personal"` (the default): each person's own; `.in(spaceId)`
  reaches the person's own copies inside that space, such as what was
  delivered to them there. A change to a person's own collection reaches only
  that person, even used in a space. `"space"`: one set per space, shared by
  its members, always used `.in(spaceId)`. `"app"`: one set for the whole
  app, shared by everyone who uses it — everyone reads it and only the app's
  server writes it, unless its `permissions` say otherwise.
- **Rules.** A declaration can also hold `unique`, `relations` (with
  `onDelete: "cascade"` or `"restrict"`), `validate` rules, a `jsonSchema`
  every record is checked against (and the handle is typed from), and
  `permissions` for `read`, `create`, `update` and `delete`, down to single
  `fields`. A rule is `owner`, `space-member`, `space-editor`,
  `space-admin`, `record-creator`, `allow` or `deny`; `"server"` — only the
  app's own server writes, and the window is refused; or `{ owns: "pro" }` —
  only people who own that product (or, in a space, a space that owns it).
  `anyOf`, `allOf` and `not` combine rules. A write that breaks one fails
  with `forbidden`, `conflict` or `unprocessable`.

#### Live queries

```js
const live = await app.tasks.live({ where: { status: "open" }, sort: "rank", limit: 50 });
const stop = live.subscribe((records, event) => render(records));
await live.put("t9", { title: "Draft", status: "open", rank: 3 });   // renders at once
await live.update("t9", (task) => ({ ...task, rank: 1 }));
stop();
await live.close();
```

- Other people's writes arrive as they commit; your own writes render before
  Terminus confirms them, and a refused one rolls back (listeners hear
  `rollback`). A write to a record whose last write is still on its way waits
  behind it, and the latest value wins.
- `persist: true` keeps what the view held on this device and opens on it next
  time — at once, offline too — then catches up. A device keeps a person's 16
  most recent queries per app, none older than 30 days.
  `db.clearLocal({ spaceId })` lets go of one space's (a space they left),
  `db.clearLocal()` of everything.
- The view is not the data. Records are the truth; never treat browser
  storage as the source.

#### Writes that must arrive

A write retried under the same key is the same write; under a new key it is a
second write — one message posted twice. So:

- Give each write that may be retried a stable key: `mutationId` on a record,
  `transactionId` on `db.transact`, `idempotencyKey` on notifications, jobs,
  submissions and services. A key is 1–200 visible characters.
- A repeat answers what the first try answered (`replayed: true` on a
  delivery or a transaction), does nothing again and rings nobody. The same key
  on a different request is `idempotency_conflict`; while the first is still
  being worked on, `still_pending`: try again later with the same key.
- Calls that leave Terminus differ. A service another developer runs gets your
  key in its own `Idempotency-Key` header, and a
  repeat is `idempotency_conflict` — never a second charge, but look before
  calling again. Terminus's own services answer a repeat with the first
  answer: the same job.

`db.outbox(name)` does all of that for writes that must survive a reload, a
closed lid or a lost connection: it keeps them on the device and sends them in
order, each step under a key fixed when it was queued.

```js
import { db } from "@terminus-ai/app-sdk";

const outbox = await db.outbox("messages");
await outbox.enqueue({
  id: message.id,                       // never reuse an entry id
  meta: { spaceId, message },           // what you draw the pending message from
  steps: [
    { type: "upload", bucket: "attachments", spaceId, path: photoPath, blob: photo },
    { type: "deliver", collection: app.messages, id: message.id, value: message, spaceId,
      notify: { title: me.displayName, body: message.text } },
  ],
});
outbox.subscribe((entries, event) => renderPending(entries));   // state: pending | sending | failed
await outbox.retry(message.id);      // a failed entry, from the step that failed
await outbox.discard(message.id);    // or drop it
```

- Steps: `upload`, `put`, `delete`, `deliver`, `transact`; at most 64 per
  entry; a step's `collection` is a handle (or a declared collection's name).
  A retryable failure is tried again with backoff (at once when the
  browser comes back online) and the entries behind it wait; a refusal marks
  the entry `failed` with `lastError` and the queue moves on. An ended session
  pauses the queue; the next session sends it.
- After an app update, what the old version queued is sent by the new one —
  once, in order, under its original keys — and your migrations bring it up
  to date as it arrives. A queue that meets `update_required` waits, keeping
  every entry, until the app is updated.
- The app's windows share one outbox, one sending at a time.

#### Transactions and rings

```js
await db.transact([
  app.cards.putOperation("c1", card, { expectedVersion: 0 }),
  { action: "put", collection: app.audit, id: "c1-created", value: { card: "c1" }, expectedVersion: 0 },
], {
  spaceId,
  transactionId: `create-${card.id}`,
  notify: { title: "Alan in #launch", body: card.title, route: `/?space=${spaceId}` },
  mentions: ["bob"],
});
```

Up to 64 puts and deletes commit together or not at all; each names its
collection by handle. `notify` rings every member who is not looking at the
space; `mentions` (up to 8 handles) rings each person named with "mentioned
you". Rings happen exactly when the write commits — never on a retry, never
without it.

`app.messages.deliver(id, value, { spaceId, mutationId, notify, mentions })`
writes one record into every current member's own copy of a personal
collection at once — how a chat stores a message (at most 50 recipients).

#### Spaces: the people an app brings together

An app makes spaces, puts people in them or invites them by handle, and
chooses which one to show — it is never opened inside one. Anyone can message
anyone: a direct space's person is in it at once.

```js
import { people, spaces } from "@terminus-ai/app-sdk";

const [match] = await people.search("bo");                        // a picker: at most 8, contacts first
const dm = await spaces.create({ kind: "direct", memberHandles: [match.handle] }); // in at once; created: false when the pair has one (per parentId)
const { space, invitations } = await spaces.create({
  name: "#launch",                        // 1–120 characters
  memberHandles: ["bob", "carol"],        // invited — or put in at once with add: true
  parentId: team.id,                      // optional: another space of this app
  meta: { topic: "Launch week" },         // optional JSON, at most 4096 bytes, every member reads it
});
await spaces.update(space.id, { name: "#launch-crew", meta: { topic: "Shipped" } });
await spaces.members.add(space.id, dana.id, "viewer");          // in at once; "editor" by default
await spaces.members.invite(space.id, "erin");                  // or asked: they join when they accept
await spaces.members.update(space.id, userId, "admin");
await spaces.members.remove(space.id, userId);
await spaces.leave(space.id);
await spaces.delete(space.id);                           // owner only: gone, data and all, for everyone

const mine = await spaces.live();                        // the spaces the person is in, kept current
mine.subscribe((list) => renderSidebar(list));
const roster = await spaces.members.live(space.id);       // { members, invitations }
const reads = await spaces.receipts.live(space.id);        // how far each member has read
await reads.update({ readThrough: reads.snapshot.headSequence });
```

- **Anyone can message anyone.** A direct space puts its person in at once;
  a group invites unless it is made (or grown) with `add`. An invitation is
  the invitee's answer, in Terminus's own notifications — an app can invite,
  never consent for anyone. Locally, that is the DEV card in the invitee's tab.
- Someone put in a space knows it once they write in it (a delivery, or a
  transaction that rings) or keep whoever added them as a contact:
  `space.known`, `space.addedBy`. File the unknown ones apart and offer
  to keep the sender as a contact.
- Roles: `owner` (the creator), `admin` (manages members), `editor`, `viewer`
  (reads, writes nothing). Every `Space` carries `myRole`, `muted` and
  `capabilities` (`canSend`, `canInvite`, `canRemoveMembers`,
  `canManageRoles`, `canUpdate`, `canLeave`, `canDelete`):
  render those instead of working permissions out.
- `kind: "direct"` is the creator and exactly one other person; it names them
  as `peer`, so a list labels a DM without reading its roster:
  `space.kind === "direct" ? space.peer?.displayName : space.name`. Direct
  spaces refuse group management.
- **Contacts and blocks are the person's, in every app.** `people.contacts.live()`,
  `people.contacts.add(id)`, `people.contacts.remove(id)` — private and one-way;
  `people.blocked.add(id)`, `people.blocked.remove(id)`, `people.blocked.list()` (each with
  `blockedAt`).
  `notifications.settings.update({ muteUnknownSenders: true })` keeps unknown
  spaces from ringing.
  At most 100 people a day who don't keep the person as a contact can be put
  in spaces by them (`rate_limited` past it).
- **A person can give your app a name and a photo.** `people.me.live()`,
  `people.me.update({ displayName })` (null shows their Terminus name),
  `people.me.setPhoto(blob)` (JPEG/PNG/WebP, 512 KiB), `people.me.removePhoto()`.
  Every person the SDK hands you in your app wears them; the id and
  `@handle` never change, and other apps show the Terminus name and photo.
- **What your app keeps for the person:** `db.usage()` (files and records,
  the figure Settings › Storage shows, with `filesByType`: image, video,
  audio and other, each `{ count, bytes }`) and `db.export()` (their
  records as one database file).
- A space holds at most 50 people. A new or joined space appears in
  `spaces.live()` in every window of the person's by itself. `delete` is
  refused with `conflict` while spaces under it (by `parentId`) exist.

#### Rooms and presence

A room is for what matters only now — typing, cursors, who is here — in one
space. Nothing in it is kept, and a page that was offline never receives what
it missed: store anything that matters in records or a stream.

```js
import { rooms } from "@terminus-ai/app-sdk";

const room = await rooms.open("typing", { spaceId });
room.subscribe(({ payload, envelope }) => showTyping(envelope.from.userId, payload));
room.subscribePresence(({ online, states }) => renderCursors(states));
await room.setPresence({ editing: "task-1" });   // this person's state in the room
await room.signal({ cursor: 42 });                // to whoever is listening right now
await room.close();                               // clears presence: the others see a leave
```

- **The echo rule.** What a page sends never comes back to its sender — not
  to that page, not to another tab of theirs — and a presence change is not
  reported to the person who made it. Draw your own change as you send it,
  and test with two people. Changes to records, files, buckets and streams
  are different: every window that can read them hears them, the writer's
  own included.
- Clearing presence (`setPresence(null)`) or closing the room is a leave, and
  so is a person's last window closing.
- While a visible window of theirs has a presence state in a space, Terminus
  treats the person as looking at it: `notify` does not ring them there. Set
  presence when a space is on screen, and clear it when it is not.
- Limits: a presence state is at most 4 KiB per person per space; an event
  payload 32 KiB; 1,200 signals and 240 other events a minute per person and
  space. `room.publish(payload, { mentions, notify })` is the ringing kind.

#### Durable streams

```js
import { streams } from "@terminus-ai/app-sdk";

const kills = await streams.open("kills", {                 // an ordered log everyone folds alike
  spaceId,
  onEvent: (event) => (event.type === "reset" ? restore(event.snapshot) : apply(event.entry.operation)),
});
await kills.append({ killer, victim });
await kills.checkpoint(scoreboard());                       // lets older entries go
```

- Every stream listener sees every entry exactly once, in order, its own
  included. A reader behind a checkpoint gets `reset` with its snapshot, then
  the entries after it.

#### Files, buckets and pictures

```js
import { files } from "@terminus-ai/app-sdk";

await files.write("exports/tasks.csv", csv, { mediaType: "text/csv" });   // the person's own files
const stat = await files.info("drafts/plan.md");
await files.write("drafts/plan.md", text, { expectedSha256: stat.sha256 });   // only if unchanged
await files.write("drafts/new.md", text, { expectedSha256: "missing" });      // only if new

const shared = files.bucket("attachments", { spaceId });   // the whole space's
const receipt = await shared.write(`photos/${id}.png`, blob, { expectedSha256: "missing" });
const photo = await shared.read(`photos/${id}.png`);            // a Blob
await shared.delete(`photos/${id}.png`, { expectedSha256: receipt.sha256 });

const shown = shared.url(`photos/${id}.png`);                    // read once a session, however many views
img.src = shown.url ?? (await shown.ready);                      // …and shown.release() when it goes away
const small = shared.thumbnail(`photos/${id}.png`, { maxEdge: 320 });   // a smaller copy, made in this browser
const cover = files.url("exports/cover.png");                   // the same for the person's own files
```

- `files` are the person's own — documents, exports they keep (`zone:
  "output"`) — at most 12 MiB each, and theirs alone even with a `spaceId`.
- A bucket holds blobs, at most 8 MiB an object, and needs no declaring.
  Opened with `{ spaceId }` every member reads it and an object stays when its
  writer leaves; without, it is the person's own.
- `expectedSha256` makes a write or delete conditional (`version_conflict`
  when it changed). A delete cannot expect `"missing"` (`bad_request`).
- `url(path)` and `thumbnail(path, { maxEdge })` (640 pixels and WebP unless
  said) show a file or an object: `url` is set at once when the bytes are
  already here, else await `ready`; call `release()` when the view goes.
- Lists page by `nextCursor`, passed back as `after`, at most 200 at a time.
- Keep blobs out of record JSON; store the path in the record.

#### Services

```js
// schema.ts: services: { web: "@terminus/web", three: "@terminus/three", calendar: "@acme/calendar", gmail: "@acme/gmail" },
//   models: { writer: "@terminus/claude-sonnet-4-6", art: "@terminus/gpt-image-2", vectors: "@terminus/text-embedding-3-small" }
const { results } = await app.web.call("search", { query: "rust http", count: 5 });
const page = await app.web.call("fetch", { url });                       // any public page
const created = await app.calendar.call("create-event", { title: "Launch" }, { idempotencyKey: "launch-1" });
const answer = await app.writer.chat({ messages: [{ role: "user", content: "Hello" }], max_tokens: 200 });
const job = await app.art.generate({ prompt: "a lighthouse" }, { idempotencyKey: "cover-1" });
const done = await job.wait();      // also stops at "reconciling": never resubmit that
const { data } = await app.vectors.embed(["first note", "second note"], { dimensions: 512 }); // in the call

const inbox = await app.gmail.call("list-messages", { q: "from:alice" }).catch((error) => {
  if (error.code === "not_connected") return null;                       // Terminus asks the person to connect it
  throw error;
});
addEventListener("terminus:connected", (event) => {                      // connected: try again
  if (event.detail.address === app.gmail.address) reload();
});
```

- A declared service is called on any of its operations, from the window or
  the app's server. `call` answers the operation's JSON — or, for one that
  runs as a job, the job; `request` answers the whole response (`status`,
  `contentType`, `body` or `bodyBase64`); `{ bytes }` sends a file. A job has
  `wait()`, `get()`, `cancel()`, `file(id)` and `save(id, path)` (into the
  person's files; results are temporary), and `app.calendar.job(id)` finds
  it again after a reload; a picture, music, speech, transcription or video
  model's `generate`, `compose`, `speak` and `transcribe` answer the
  same kind of job, and an embedding model's `embed` answers its vectors in
  the call.
- `{ address, reads, writes }` lends the service the collections named, as
  the person calling, for each call; it reaches nothing else of the app's.
- Who pays: the person using the app (`quota_exceeded` when short). A call
  from an operation a guest runs, or from the app's own schedule, is paid by
  the app's owner within the app's daily budget.
- A connector service works in each person's own account: Terminus sends
  their sign-in or key to its API, and the app never sees it. Someone who has
  not connected it is answered `not_connected` (403; `details` name the
  service and whether it connects by signing in or with a key) and nothing is
  sent; the SDK says `terminus:not-connected` once per service and page, and
  `terminus:connected` once they have connected.
- An API that needs a key: call it from the app's server, naming its host in
  `network` and its key in `secrets` — or publish it as a service.

#### Products

```js
// schema.ts: products: { pro: { kind: "subscription" } }
proOnly.hidden = !(await app.pro.owned());
buyButton.onclick = async () => {              // buy() only from a click: the sheet opens a window
  try {
    await app.pro.buy();                       // Terminus's own purchase sheet; resolves once owned
    proOnly.hidden = false;
  } catch (error) {
    if (error.code !== "cancelled") throw error;   // closed the sheet; not_available: no price yet
  }
};
const product = await app.pro.info();          // { id, kind, price, periodDays, owned, expiresAt, renews, spaceId }
```

- The app never draws the purchase sheet; Terminus charges the buyer's
  credits and pays the maker's share.
- A product has no price until the person sets one — on the app's page in
  Creations, under Products, once a release that declares it is published —
  and `buy()` answers `not_available` until then. Tell them.
- Keep what only buyers get with rules: `{ owns: "pro" }` in a collection's
  `permissions`, or `owned()` asked in the app's server. `owned()` in the
  window only shapes the interface.
- `buy({ spaceId })` and `owned({ spaceId })` are a space's: buying for a
  space takes one of its admins.
- A subscription renews each period until the person cancels it in System
  Settings, and stays owned until the period they paid for ends.

#### The app's server

```js
// server/index.ts
import { defineService, OperationError } from "@terminus-ai/app-sdk/service";
import app from "../schema";

export default defineService({
  submit: {
    description: "Keep a player's best score",
    input: { type: "object", properties: { score: { type: "integer", minimum: 0 } }, required: ["score"] },
    async run({ score }, ctx) {
      const player = ctx.caller.userId;        // who called: key on it, never on the input
      await app.scores.update(player, (current) =>
        current && current.best >= score ? undefined : { best: score });
      return { saved: true };
    },
  },
  top: {
    guests: true,                              // signed-out visitors may call it too
    async run() {
      const { records } = await app.scores.find({}, { sort: "-best", limit: 20 });
      return records.map((record) => record.value);
    },
  },
  forecast: {
    timeoutSeconds: 30,                        // over 10: only jobs and schedules run it
    network: ["api.weather.example"],          // hosts by name, never the whole web
    secrets: ["WEATHER_KEY"],                  // set by the person; .env.local for terminus dev
    async run({ city }, ctx) {
      const answer = await fetch(`https://api.weather.example/tomorrow?city=${encodeURIComponent(city)}`, {
        headers: { authorization: `Bearer ${ctx.secrets.get("WEATHER_KEY")}` },
      });
      if (!answer.ok) throw new OperationError("upstream", "The weather service did not answer");
      return answer.json();
    },
  },
  reset: {
    schedule: { every: "monday", at: "00:00", timezone: "Europe/Paris" },   // the app's own: runs as the app
    async run() {
      for await (const score of app.scores.scan()) await app.scores.delete(score.id);   // a new week's board
    },
  },
});
```

```js
// in the window
import { jobs, server } from "@terminus-ai/app-sdk";
await server.call("submit", { score: 42 });
const leaders = await server.call("top");
const queued = await jobs.run("forecast", { city: "Oslo" });   // over 10 s: a job
```

- `terminus push` builds `server/index.ts` with the app into the app's
  server, and Terminus runs a fresh copy for each call. Nothing about it
  goes in `terminus.json`. Code at the top of the module runs once, when it is
  built.
- An operation: `run(input, ctx)`, `description`, `input` (a JSON Schema
  checked first: `bad_request`), `timeoutSeconds` (1–120, 10 unless said),
  `memoryMb` (16–512, 128), `network`, `secrets`, `schedule` (`{ every, at,
  onDay, timezone }` — `every` being `"15m"`, `"hour"`, `"day"`, a weekday or
  `"month"` — or `{ cron, timezone }`; UTC unless said) and `guests: true`
  (never together with a schedule). At most 32 operations.
- `server.call(op, input)` from the window waits at most 10 seconds; its
  input is JSON of at most 256 KiB. Refusals: `not_found` (no such
  operation), `bad_request` (the input, or an operation only background work
  runs), `deadline_exceeded`, `refused` (the operation threw
  `OperationError`: its code and status in `details`, its words in the
  message), `upstream_failed` (it threw anything else; the message goes to the
  log) and `server_unavailable`.
- **Who a call runs as.** The person — a call from their window, their jobs
  and triggers, a command an agent called as they allowed: the handles read
  and write as them, and services bill them. A guest — an operation marked `guests: true`,
  on an app open to guests: it reads the app-wide collections that allow
  reading and writes app-wide collections through the `"server"` rule, and
  the services it calls are paid by the app's owner within the daily budget.
  The app — the operation's own `schedule`: app-wide collections only, paid
  by the owner within the daily budget.
- Inside `run`, the handles and the SDK act as whoever the call is for, and a
  write from here passes the `"server"` rule; `notifications.create` tells
  that person. `ctx.caller` is `{ kind, userId }` (`person`, `guest` or
  `app`), `ctx.trigger` what started it (`call`, `job`, `trigger`,
  `schedule`, `agent`), `ctx.secrets.get(name)` a secret the operation
  names, `ctx.cache` JSON values kept for the release (`get`,
  `set(key, value, ttlSeconds)`, `delete`; an hour unless said, a week at
  most), `ctx.invocationId` the call's id in the log, `ctx.spaceId` the space
  an agent's command works in.
- `network` names hosts (`api.example.com`, `*.example.com`), https only:
  an app's server never gets the whole web. For any page someone names,
  call `@terminus/web`'s `fetch` instead.
- **Secrets**: named by the operations that read them. The person sets them
  on the web (the key on the app's page in Creations) or with
  `terminus secrets set NAME` in the app's folder, which reads the value at a
  hidden prompt or from stdin — never on the command line, never in a file.
  For `terminus dev`, they go in `.env.local` (never uploaded). A value is
  never shown again, a new one holds from the next call, and a fork never gets
  them.
- **The daily budget**: what the app spends on its own — its schedules and its
  guests' calls — is paid by its owner up to the daily budget, $1 a day unless
  changed in the app's settings in Creations. Past it those calls are refused
  until the next UTC day.

#### Background work and notifications

```js
import { jobs, notifications, routes, triggers } from "@terminus-ai/app-sdk";

// Each runs one of the app's server operations, for this person.
const queued = await jobs.run("remind", { title: "Stand-up" }, { idempotencyKey: "standup-0923" });
const finished = await jobs.wait(queued.id, { timeoutMs: 60_000 });   // succeeded | failed | cancelled; result = what the op returned
await triggers.schedule("remind", "0 9 * * *", { name: "morning", timezone: "user", input: { title: "Stand-up" } });
await triggers.setEnabled("morning", false);
const { url } = await triggers.webhook("on_payment");                  // shown once; triggers.rotate for a new one
await triggers.watch("price_moved", pageUrl, { pattern: "\\$[0-9.]+", condition: { changedByPct: 2 } });

await notifications.create({ title: "Export ready", body: "3 files", route: "/?view=exports" }, { idempotencyKey: `export-${id}` });
routes.listen((route) => showView(new URL(route, location.href).searchParams.get("view")));
```

- Nothing about background work goes in `terminus.json`: the app's code
  makes its jobs, schedules, webhooks and watches at runtime, each the
  person's (one set per installation, kept across releases), and each runs
  its operation as that person with its whole `timeoutSeconds`. A cron has
  five fields, each `*`, `*/N` or one number; `timezone` is an IANA name or
  `"user"` (UTC otherwise). A webhook's op gets `{ webhook, delivery_id,
  delivery_key, received_at, payload }`; a watch's op gets `{ event, input }`
  when what its `pattern` matches changes (polled hourly, at most every 15
  minutes). A name defaults to the op's; setting it again remakes it.
- Work that is the app's, not one person's, is an operation with its own
  `schedule` (see The app's server).
- `notifications.create` reaches only the signed-in person (title ≤ 160
  characters, body ≤ 2,000); to reach others, ring them with a write. While
  they paused the app's notifications nothing is sent and the answer says
  `paused`.
- A notification's `route` is where it opens the app. An app that is already
  open receives it through `routes.listen` instead of reloading.

#### Fit into the desktop

The desktop answers the web's own APIs; nothing of Terminus's is imported.

```js
document.title = `${unread} unread`;          // the window's title
navigator.setAppBadge(unread);                // the count on the Dock icon, up to 99,999
navigator.clearAppBadge();                    // back to the app's unread notifications
await navigator.share({ title, text, url, files });   // the desktop's share sheet
window.launchQueue?.setConsumer(async ({ files }) => {
  for (const handle of files) openDocument(await handle.getFile());   // the files it was opened with
});
```

```json
{
  "file_handlers": [
    { "action": "/open", "accept": { "text/markdown": [".md"], "text/plain": [".txt"] } }
  ],
  "share_target": {
    "action": "/share",
    "params": { "title": "title", "text": "text", "url": "url" }
  }
}
```

- The manifest is `manifest.webmanifest` in the build, or the file
  `<link rel="manifest">` names in `index.html`. Terminus reads its
  `file_handlers` (at most 8, each `action` a path in the app: Files lists
  the app under Open With for those types) and `share_target` (the app
  appears in the share sheet), and nothing else.
- The share sheet offers Copy, Save to Files and the apps that take shares.

#### Logs

```js
import { log } from "@terminus-ai/app-sdk";

await log.info("exported", { count: 3 });              // at most 60 a minute
```

- An app has one log: the window's log calls, what the window did not catch
  (caught for you), each run of a server operation — from a window, a job, a
  trigger, a schedule or an agent's command — with its `console.log` lines, and
  each service call. Kept 7 days, errors 30.
- Its maintainers read it with `terminus logs @handle/my-app`
  (`--level error`). A person appears in it under a stand-in name that
  means something in this app's log alone.

#### Tools for agents

Agents use an app through its tools — Norbert, and coding agents through
`terminus mcp`. Nothing is written for them: the tools come from what the app
already has, and `schema.ts` says which of it agents may use.

```js
// schema.ts
import { schema, t } from "@terminus-ai/app-sdk";

export default schema({
  collections: {
    notes: { ownership: "personal", search: { fields: ["title", "body"] }, agents: "write" },
    tags: { ownership: "personal", agents: "read" },
  },
  agents: {
    shareNote: {                     // a function the window already uses
      description: "Share the open note with someone; they are notified.",
      effect: "send",
      input: { handle: t.name() },
    },
    digest: {                        // one of the app's server operations
      description: "Summarise the notes written this week.",
      effect: "read",
    },
  },
});
```

```js
// in the window, once at startup
import { commands } from "@terminus-ai/app-sdk";
import { shareNote } from "./share";   // what the Share button calls

commands({ shareNote });
```

- **Data tools.** `agents: "read"` on a collection gives agents `list_notes`,
  `get_note` and — when it declares search fields — `search_notes`;
  `agents: "write"` adds `create_note`, `update_note` and `delete_note`.
  Terminus answers them itself, as the person and under the collection's own
  rules; no code of the app runs. Without the word, agents never see the
  collection. Use `"write"` only where saving a record is the whole job;
  anything with more to it is a command.
- **Commands** are the `agents` section: each a key of 1–64 letters, digits,
  `-` or `_`, starting with a lowercase letter; a `description` of 1–1,024
  characters; an `effect`; and an optional `input` — fields made with `t`
  (`t.id()`, `t.name()`, `t.text(max)`, `t.string(max)`, `t.bool()`,
  `t.int(min, max)`, `t.number(min, max)`, `t.choice(...)`,
  `t.list(item, max)`, `t.object({...})`, `t.date()`, `t.time()`,
  `t.version()`, `t.query()`, `...t.page()`; each required unless
  `.optional()`), or a whole JSON Schema with `type: "object"`, at most
  16 KiB. At most 64 commands. Agents call a command by its key in snake
  case — `shareNote` is `share_note` — so no two keys may come out the same,
  or the same as a data tool.
- **A server command** is a key that names one of the app's server
  operations. Terminus runs that operation for the agent, as the person,
  wherever the agent is; its input is the operation's unless the declaration
  gives one. It is held to its effect: `read` changes nothing, `write`
  changes data and reaches nobody, and only `send` delivers to people, rings
  them or notifies them.
- **A window command** is any other key: a function the app's window already
  has, handed to `commands({ … })` once at startup. It runs in the app's
  window in the Terminus desktop, so only agents working in the desktop reach
  it — coding agents on that computer, and Norbert in conversations held
  there; when the app is not open, the desktop opens it out of sight.
  `commands()` checks the input against the declaration before the function
  runs and answers what it returns. To refuse, throw: the agent reads the
  error's message, and its `code` when it has one. Every key must be declared
  in `schema.ts` (anything else is a TypeError); outside the desktop
  `commands()` registers nothing and throws nothing.
- What must work without the desktop — Norbert on the web, agents that run on
  their own — is a server command. A window command's effect is the app's
  word, and Terminus holds only server commands to theirs: a command that must
  never reach people belongs on the server.
- Every app window in the desktop also gives agents `whats_open` (its page,
  its title, the record it shows, the text the person selected) and `open`
  (a page, or a record at its collection's `search.desk.route`, which the app
  takes through `routes.listen`).
- Write commands for people first: a named function that takes one input
  object, never logic inside a click handler. Keep them narrow: never one
  that runs whatever query or request an agent sends, fetches any URL it is
  given, or hands out credentials.
- **Access** is the person's, in System Settings → App access: per app, for
  Norbert and for coding agents, Off, Read only, or Read and write, and Send
  as its own yes — in their own data and the spaces they pick. An agent sees
  only the tools that reaches. The Terminus row there covers the desktop's
  own tools: listing windows and taking pictures of them (Read only), and
  opening, focusing and closing them (Read and write).
- Test while `terminus dev` runs: open its App access page, let coding agents
  in, then `terminus apps <app> --dev`, `terminus apps <app> <tool> --dev`, or
  `terminus mcp --dev` as an MCP server. With the Terminus desktop open,
  `terminus dev --desktop` opens the app's window in it, and
  `terminus mcp --dev` serves its window commands as well.

#### Limits

| Limit | Value |
|---|---|
| Record value | 128 KiB |
| Operations in one `db.transact` / one `putMany` commit | 64 |
| Items in one page of any list | 200 |
| People one write or event mentions | 8 |
| Recipients of one delivery / people in a space | 50 |
| Space `meta` | 4096 bytes |
| Presence state per person per space | 4 KiB |
| Event payload | 32 KiB |
| Events a minute, per person and space | 240, or 1,200 signals |
| Bucket object | 8 MiB |
| File | 12 MiB |
| Idempotency key | 1–200 characters |
| Declared per release | 32 collections, services and products each; 8 channels |
| Server operation | 10 s from a window, up to 120 s in the background; 16–512 MB |
| Server operations per app | 32 |
| Commands for agents | 64 per release; a description of 1–1,024 characters, an input of 16 KiB |
| Job or `server.call` input | 256 KiB |
| Log lines a minute, from the window | 60 |

`LIMITS` in the SDK carries them; the SDK checks what it can before a
request leaves the page.

### Data rules

- Structured, queryable state goes in declared collections. Those records are
  the canonical copy, and they belong to the person — or to a space, or, for an
  app-wide collection, to everyone using the app — not to the app's maker.
- What members share lives in a space: a space collection, a space bucket or a
  durable stream. A person's own `files` are never shared.
- Blobs go in buckets, files people keep in `files` — never inside record
  JSON.
- Keep record ids stable, and change the shape of records with a migration
  (below), never by rewriting them from the app: data outlives the interface
  that wrote it, and people download it as SQLite files.
- No second database and no backend of your own. Other apps and agents reach
  this data through permissions the person controls.

### Migrations

An app's data comes as real SQLite files: each person's `data.sqlite`
(everything they keep in the app; copies inside spaces carry a `space_id`)
and one file per space (its `ownership: "space"` collections). A collection is
a table named after it, a record a row: `id`, `data` (the JSON object),
`created_at`, `created_by`, `updated_at`, `updated_by`, and `space_id` in
`data.sqlite`. `_terminus_meta` (key, value: the app, person or space,
`data_version`), `_terminus_people` (id, handle, name) and, in
`data.sqlite`, `_terminus_deliveries` are read-only. People download these
files from the app's Data folder on the desktop.

When a release changes the shape of records, it carries a migration: a
`.sql` file, named after the release that brings it, that runs once on every
file of its kind, in version order, before that release writes to it.
`terminus push` sends the folder. It is `migrations/` at the root unless
`terminus.json` names another (`"migrations": "db/migrations"`, a path inside
the app folder); the layout inside stays the same, and the release carries
it as `migrations/` either way.

```
migrations/0.0.2.sql          # each person's data.sqlite
migrations/spaces/0.1.0.sql   # each space's file
```

```sql
-- migrations/0.0.2.sql
UPDATE tasks SET data = json_set(data, '$.status', 'open') WHERE data->>'status' IS NULL;
DELETE FROM drafts WHERE json_array_length(data, '$.blocks') = 0;
```

- A new migration's version is above the last published release and no
  higher than the one that ships it; a published migration is never renamed
  or deleted. Each run is one transaction.
- Allowed: `INSERT`, `UPDATE`, `DELETE` (and upserts) on collection tables;
  `DROP TABLE <collection>` (every record of it goes);
  `ALTER TABLE <a> RENAME TO <b>` (refused when `b` has records — merge with
  `INSERT … SELECT`); `SELECT`, `WITH`, indexes, `TEMP` tables, views and
  triggers as scratch work. Refused before anything runs: `ATTACH`, `PRAGMA`, `VACUUM`,
  extensions, transaction statements, writes to `_terminus_` tables, changes
  to `created_*`/`updated_*` on existing rows. A new row may set `created_at`
  and `created_by`.
- A collection with no records in a file reads as an empty table there;
  writing to it creates its records. Ids and values keep their usual rules
  (1–128 id characters, a JSON object of at most 128 KiB). SQLite 3.46.
- **The any-subset rule:** a migration must be right on any subset of a
  file's rows — late data (a reinstall, Put Back, writes queued offline by an
  older version) is migrated on just those rows. Row-by-row updates, deletes
  and derived rows pass; totals, ranks, de-duplication across rows,
  `random()` and `'now'` do not.
- When: a person's file migrates when they open the newer release (the
  desktop says Updating); a space's file when its first member on the newer
  release opens it (writes wait; reads carry on); late rows as they arrive; a
  new install starts at the latest migration. `terminus dev` migrates local
  data when it starts, and a Test window its test data.
- A failing statement undoes the whole run. The person stays on the release
  they had, and the app still opens; a space stays read-only for members on
  the newer release until a release corrects the migration (a correction
  reaches only data still waiting on it).
- Once a space's file has passed a migration, older versions of the app can
  read it but their writes answer `update_required`; the desktop offers the
  update. A newer release may deliver newer-shaped records to someone who has
  not updated: skip what you cannot read.
- Rehearse before publishing: `terminus data export` (or a `data.sqlite`
  downloaded from the desktop), then `terminus data migrate --check <file>`.
  It runs pending migrations on a copy with Terminus's rules and SQLite,
  prints rows added, changed and removed per table, writes
  `.out/migrations/report.json`, and runs each migration on random halves to
  catch any-subset breaks.

## Agents

An agent is `AGENT.md` (its instructions), `terminus.json` (tools, models,
triggers) and any files it should read — the whole folder ships with it,
read-only. It runs on Terminus for each person who installs it, with their own
connected accounts.

```json
{
  "kind": "agent",
  "id": "@handle/release-notes",
  "version": "0.0.1",
  "models": { "default": "openai:gpt-5.6-luna", "mode": "all" },
  "tools": [
    "service:@terminus/web",
    { "id": "github.read", "when": "Read this week's merged pull requests." },
    "skill:@acme/brand-voice@1.2.0"
  ],
  "triggers": {
    "weekly": { "schedule": { "every": "friday", "at": "16:00", "timezone": "user" } }
  }
}
```

- It holds exactly `kind`, `type` (only when it is observed), `id`,
  `version`, `models`, `tools` and `triggers`, in that order; the
  CLI refuses any other key.
- **Tools**: services — `service:@publisher/slug` grants every operation of
  one, `service:@publisher/slug#operation` just that one. Terminus's own are
  the agent's built-in tools: `service:@terminus/web` (web search and
  reading pages), `service:@terminus/bash`, `service:@terminus/python` and
  `service:@terminus/javascript` (its `bash`, `python` and `javascript`
  tools: programs over its workspace, and `python3` and `node` also work
  inside a bash script); any other service's operations that answer
  JSON are tools of their own. `model:@publisher/slug` gives it a picture,
  music, speech, transcription or video model as a tool (`generate_image`,
  `compose_music`, `generate_speech`, `transcribe_audio`,
  `generate_video`; a chat or embedding model is none). `schedules.manage` and `watches.manage` let it
  manage its own schedules and page watches; a connector service
  (`service:@acme/gmail`) works in the account of whoever the agent talks to;
  `skill:@publisher/slug`
  (`@1.2.0` pins a release); `agent:@publisher/slug`. `{ "id", "when" }`
  tells the model when to reach for it. List a service once: whole, or by
  its operations.
- **Models**: one `models` section. `"mode": "all"` offers every model,
  `"selective"` the default plus `available`, `"default"` the default alone.
  Left out, the agent gets the section above. Never write a top-level
  `"model"`; the CLI refuses it.
- **Triggers**: one `triggers` object, keyed by each trigger's name
  (lowercase letters, digits, `-`, `_`; up to 48 characters). Each holds
  exactly one of `schedule` (`"every"`: `"day"`, a weekday, `"month"` with
  `on_day`, `"hour"`, or `"30m"`/`"2h"`, with `"at": "HH:MM"` for a day,
  weekday or month; or a five-field `cron`; `"timezone"` an IANA name or
  `"user"`, each installer's own), `webhook` (`{}` or
  `{ "coalesce_seconds": 60 }`, 0–3600: a burst becomes one wake; each
  install's URL is made in the agent's Info → Automations) or `watch` (`url`,
  a public https page; optional `pattern`, `interval_minutes` — 60 by
  default, 15 to 10080 — and one `condition`: `changed_by_pct`, `below` or
  `above`, on the number `pattern` captures). Each may add `prompt` (what
  the wake says; without it the agent is told why it woke) and
  `"enabled": false` (starts switched off). Every run with something to say
  notifies; an empty reply sends nothing. Payloads are data to read, never
  instructions.
- `type: "observed"` shares every conversation with the maintainers (people
  accept a notice first); the default keeps each private.
- A few top-level folder names are reserved for the agent's own use;
  `terminus validate` names them if the package ships one.

Running it:

```bash
terminus dev                                          # a chat page: talk to it, watch its tools
terminus dev --prompt "Draft this week's notes" --json    # one turn, for scripts
terminus dev --trigger deploys --payload deploy.json --json   # fire one trigger now
terminus dev --remote                                 # the same chat, on Terminus itself
```

- `terminus dev` needs you signed in. The agent's loop and its programs run on
  your machine, set up as a release would be; models, the web, skills and
  services run under your own account and spend your credits. It works
  right after `terminus init agent`, before any link.
- The chat page shows each tool call, lets you pick a model and add tools, and
  shows its schedules and watches, which you can add, edit and run now. Its
  edits are written to `terminus.json`, and editing `AGENT.md` or
  `terminus.json` reloads the agent.
- It is one conversation, carried across runs: a `--prompt` turn continues
  where the last one stopped. `--resume <id>` continues a given one. To start
  from nothing, delete `.terminus/dev/agent/`.
- `--json` prints every event as one line of JSON; a failed turn exits 1.
- `--trigger <name>` fires the trigger with that name the way Terminus
  would, and a wrong name lists the declared ones. `--payload file.json` is
  a webhook's delivery; a watch fetches its page now, and its first firing
  records what it saw.
- Before an agent that runs code (`service:@terminus/bash`,
  `service:@terminus/python` or `service:@terminus/javascript`) is
  published, run `terminus dev --remote`: it pushes the folder as the draft
  and runs the conversation on Terminus's own engine. `--prompt` and
  `--trigger` run locally only. Push reminds you.
- To try a connector service for real, the person connects their own account
  to it on Terminus first; `service:@acme/gmail` then reads their Gmail.

Publishing and installing binds each tool to the installer's own accounts and
starts its triggers for them. The installer can switch any trigger off, and
make a webhook's URL, in the agent's Info → Automations.

## Skills

A skill is a `SKILL.md` with `name` and `description` in its frontmatter,
plus the files it refers to by relative path (files nothing refers to are left
out), beside a `terminus.json` holding `kind: "skill"`, the `id` it is
linked to and the `version` its next release goes out as — nothing else: a
name or description there is refused.

```bash
terminus init skill release-notes --description "Draft release notes. Use when asked for release notes or a changelog."
terminus validate release-notes
terminus remote add @handle/release-notes release-notes   # once the skill exists on the web
terminus push release-notes
```

- `terminus clone @handle/slug` brings one down with its address in
  `terminus.json`; `status`, `diff`, `log` and `pull` work as for an
  app. A push lands on the skill's draft; people keep the published
  version until it is published again on the web. When the version in
  `terminus.json` is already released, push writes the next one in first.
- Write the `description` as when to use the skill, not what it is: it is what
  search ranks and what makes an agent reach for it.
- Using skills in a coding agent: `terminus search "pdf" --kind skill`, then
  `terminus skills install @publisher/skill --agent claude` (or `codex`;
  `--global` for your home folder). An install loads the latest release each
  time it is used. `terminus skills use "make a slide deck"` searches and
  prints the chosen skill's instructions to follow now; `terminus skills` lists
  every skills command. A paid skill doesn't install: it is used on Terminus,
  never handed out.

## Services

A service is an API, and `"type"` in its `terminus.json` says which kind:
`"hosted"` — code Terminus runs for you as WebAssembly, a fresh copy
per call — or `"external"` — an HTTPS API you run yourself. `terminus.json`
is only its header; the name, description and price are set on the web:

```json
{ "kind": "service", "type": "hosted", "language": "rust", "id": "@handle/weather", "version": "0.0.1" }
```

- `language` is how the CLI builds it, on your machine, with that language's
  own tools: `rust` (what `terminus init service` starts; cargo, target
  wasm32-wasip2), `typescript` (`defineService`), `python` (componentize-py,
  from `app.py`), `go` (TinyGo), `c` or `cpp` (wasi-sdk, wit-bindgen). Pick
  one with `terminus init service <name> --template <language>`; a tool that is
  missing is named, with how to install it.
- A hosted service in TypeScript is `src/service.ts`, whose default export is
  `defineService({...})` from `@terminus-ai/app-sdk/service` — the same shape
  as an app's server (`terminus init service my-service --template typescript`,
  then `npm install`). Each operation has `description`, `input` (JSON
  Schema, checked before it runs) and `run(input, ctx)`, and declares what a
  call may reach: `network` (web hosts, `"api.example.com"` or
  `"*.example.com"`; `fetch` works as in a
  page, https only), `secrets` (names read with `ctx.secrets.get(name)`),
  `services` (`"@handle/slug#operation"`, called with
  `ctx.services.call(address, operation, input)`), `runtime` (the app
  data a caller may lend it: an app lends the collections it names in
  `reads` and `writes`), `timeoutSeconds` (1–120, 10) and `memoryMb`
  (16–512, 128). Anything undeclared fails. `job: true` makes a call answer
  at once with a job its caller waits on (300 seconds at most, its answer
  kept an hour); `dailyLimit` caps the calls each caller makes of it in a UTC
  day (`rate_limited` past it, nothing charged). `ctx.settings.get(name)`
  reads a setting; `ctx.cache` keeps JSON values per version; `ctx.caller`
  is `{ userId, service }`. Throw
  `OperationError(code, message)` to refuse in your own words; any other
  error fails the call and is logged. Top-level code runs at build time, not
  per call.
- In Rust, Python, Go, C or C++, the starter serves the contract at
  `GET /openapi.json` (the same limits as `x-terminus-network`,
  `x-terminus-secrets`, `x-terminus-services`, `x-terminus-runtime`,
  `x-terminus-timeout-seconds` and `x-terminus-memory-mb` per operation,
  and `x-terminus-job` and `x-terminus-daily-limit` where it wants them),
  answers each operation at `POST /operations/<name>` with its input as JSON,
  refuses with `{"error": {"code", "message"}}` and a 4xx, and reads secrets
  and settings through `wasi:config/store`; a helper file beside the code,
  named `terminus` in the language's own extension, does the plumbing. Any
  other language that builds a `wasi:http` component works too: leave it at
  `dist/service.wasm` or name it with `"build"`, and let a `package.json`
  build script make it.
- A call never opens a socket: it reaches the web over HTTPS, to the hosts its
  operation declares. A runtime that carries socket code anyway (Python's)
  still runs, and any connection it tries is refused.
- Secrets belong to the service, not a release: `terminus secrets set NAME`
  (value at a hidden prompt or on stdin — never in a file, never on the
  command line) or the key on its page in Creations; a new value holds from
  the next call. For `terminus dev`, put them in `.env.local` (never uploaded).
  `terminus push` names any the draft needs that are not set. `terminus
  secrets` works the same in an app's folder, for its server.
- Settings are values the code reads by name — a provider, a model list, a
  rate — changed without a release: `terminus settings set NAME value`,
  `terminus settings` to list them, `terminus settings unset NAME`, or the
  sliders beside the key on its page. Unlike a secret a setting is read back
  whole by everyone who works on the service, so never put a key in one.
  Names are capitals, digits and `_`, never `TERMINUS_` or a secret's
  name; at most 64, each at most 4 KiB. A new value holds from the next call.
- Files for pages — a library's modules, stylesheets, fonts — go in an
  `assets/` folder (or the one `"assets"` names in `terminus.json`). A
  service of files alone has no `"type"`. Apps' pages load them by its
  address (`import * as THREE from "@terminus/three"`), at the version the
  app was published against; a released version's files never change.
- A service calling another pays that service's price from its owner's
  credits, within the service's daily budget (the gear on its page → Daily
  Budget, $1 a day until changed). The callee sees the calling service, never
  the person. Chains go at most four deep and never loop.
- An API someone runs is `"type": "external"` (`terminus init service
  --template external`), and its `openapi.json` says where it is and what it
  takes, in OpenAPI's own places: its URL in `servers`, its credential in
  `securitySchemes` and `security`. On the scheme Terminus uses, one line
  says what to send: `"x-terminus-secret": "VENDOR_KEY"` (a service secret,
  set with `terminus secrets set VENDOR_KEY`), or `"x-terminus-signed":
  true` (Terminus signs each call; only to a server whose ownership check
  passed). terminus.json stays the header.
- A connector is an external service that works in each person's own
  account: they connect it once, and every call they make goes out as them.
  Either they sign in — an `oauth2` scheme with the `authorizationCode` flow
  (`authorizationUrl`, `tokenUrl`; provider parameters such as Google's
  `?access_type=offline&prompt=consent` go in the `authorizationUrl`),
  `"x-terminus-client-id"` and `"x-terminus-secret": "CLIENT_SECRET"`, the
  service secret holding your OAuth app's client secret, with the scopes
  `security` names — or they paste their own key: `{"type": "http",
  "scheme": "bearer", "x-terminus-person": true}` (or an `apiKey` scheme
  with that line). A connector must name `"x-terminus-health"`: a path that
  only reads who the person is (Gmail's `/users/me/profile`), which checks
  a pasted key and names the account. Register the redirect URI the service's
  page in Creations shows in your OAuth app. Before publishing, connect your
  own account on that page, then Test connection: the test calls it as you.
  Only a person's own call can use a connector, never another service's; one
  who has not connected it is refused with `not_connected`. A skill names
  one it needs as `[[Gmail]](service:@handle/gmail)`.
- An external operation's OpenAPI `parameters` (`in`: `path`, `query`
  or `header`) take their values from the call's input by name, and the
  rest is its body; a header parameter with a `const` or `default` is sent
  with that value on every call.

```bash
terminus init service weather && cd weather            # Rust; --template typescript|python|go|c|cpp
terminus dev                                           # build it and run it here, with a test page
terminus dev --call greet --input '{"name":"Ada"}'     # one call from the terminal, its answer printed
terminus remote add @handle/weather                    # once it exists on the web
terminus secrets set WEATHER_KEY
terminus service test . forecast --input '{"city":"Oslo"}'   # updates the draft, then calls it on Terminus
terminus push                                          # build it and update its draft
```

- `terminus dev` runs a hosted service as Terminus does — a fresh copy per
  call, only what each operation declares, a call past its time stopped —
  and serves `dev/index.html` (without one, a page with a form for each
  operation), which calls
  `window.__SERVICE_DEV__.invoke(operationId, { input, query, bytes,
  idempotencyKey })`; it rebuilds when the code changes. A hosted service and
  the app that calls it run together: `terminus dev ./my-app --service
  ./my-service`.
- `terminus service test <dir> <operation>` sends `--input <json>` or
  `--file <path>` (at most 8 MiB), with `--query <json>`; no credits move.
- A caller's idempotency key reaches your service in the `Idempotency-Key`
  header; a caller who repeats it is answered `idempotency_conflict` and never
  charged twice.
- `terminus service inspect @publisher/slug` shows any service's operations
  and price; `submit`, `job --wait`, `jobs`, `cancel` and `save` run jobs from
  a terminal.

## Things that cost people an afternoon

- **Build before `terminus dev`.** Without `npm run build` it stops: "No built
  bundle".
- **Your own signals never come back.** Test anything shared with
  `terminus dev --members 2`, watching from the other person's tab.
- **Retry with the same key.** A retry under a new key is a second write.
- **`session_ended`, `unauthorized` and `guest` are final.** Stop, say so,
  never loop.
- **Declare it in `schema.ts` first.** A collection, service, product or
  channel the release does not declare is refused; reach each through its
  handle, `app.<name>`.
- **A window waits 10 seconds.** A server operation that may run longer, or
  has a schedule, runs only as a job or on its schedule: `server.call`
  refuses it with `bad_request`.
- **The app's server never gets the whole web.** Name hosts in `network`;
  call `@terminus/web`'s `fetch` for pages people name.
- **`buy()` only from a click**, and a product with no price answers
  `not_available`: the person sets prices on the web.
- **`terminus dev` calls the real services**, billed to you (at most 200 a
  day), unless `--service` runs one of yours beside the app.
- **The window is small.** 640×460, down to 400×300. Look at it narrow.
- **A push replaces the draft.** It keeps no history: when status says the
  draft moved, pull first, or the push drops what changed there.
- **A Test window shows an older page.** It runs the pushed build: push.
- **An invite in a Test window finds nobody.** That test person's window is
  not open yet.
- **`update_required` means the data moved on.** A newer release's migration
  ran on it; this version can read but not write. Don't retry: the desktop
  offers the update, and queued writes wait for it.
- **A migration must work on any subset of rows.** No totals, ranks,
  de-duplication, `random()` or `'now'`; rehearse with
  `terminus data migrate --check`.

## Before you say it is done

```bash
terminus validate     # the checks a push runs
terminus status       # is the draft what you have locally?
terminus dev          # open it once more and use it, as two people if it is shared
terminus push
```

Then tell the person: what to try in Test, what they still need to set on the
web (secrets, product prices, the daily budget), and that publishing — the
version and what people get — is theirs, on the page `terminus push` printed.
