@cotal-ai/connector-opencode 0.77.0 → 0.77.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -43385,6 +43385,9 @@ function configFromEnv(env = process.env) {
43385
43385
  feedbackUrl: env.COTAL_FEEDBACK_URL?.trim() || void 0
43386
43386
  };
43387
43387
  }
43388
+ function eventPlaneArmed(config2, env = process.env) {
43389
+ return envFlag(env, "COTAL_EVENTS") === true || config2.eventsRequired === true;
43390
+ }
43388
43391
 
43389
43392
  // ../connector-core/dist/agent.js
43390
43393
  import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
@@ -62456,10 +62459,10 @@ function date4(params) {
62456
62459
  config(en_default());
62457
62460
 
62458
62461
  // ../connector-core/dist/docs-bundle.generated.js
62459
- var DOCS_VERSION = "0.77.0";
62462
+ var DOCS_VERSION = "0.77.1";
62460
62463
  function loadDocsBundle() {
62461
62464
  return {
62462
- "version": "0.77.0",
62465
+ "version": "0.77.1",
62463
62466
  "generatedFrom": "docs/*.md + SPEC.md + spec/cotal-lang.md + spec/cotal.schema.json",
62464
62467
  "pages": [
62465
62468
  {
@@ -62530,14 +62533,14 @@ function loadDocsBundle() {
62530
62533
  "title": "`cotal` CLI reference",
62531
62534
  "kind": "Reference: describes the TypeScript reference implementation (the `cotal` CLI), not the wire contract.",
62532
62535
  "summary": "cotal is the operator command line for the reference implementation: bring a mesh up, mint identities, launch agents, watch what they do, and tear it all down.",
62533
- "body": "# `cotal` CLI reference\n\n> **Reference**: describes the TypeScript reference implementation (the `cotal` CLI), not the wire contract. \xB7 **For:** operators \xB7 **Wire contract:** [SPEC](../SPEC.md)\n\n`cotal` is the operator command line for the reference implementation: bring a mesh up, mint\nidentities, launch agents, watch what they do, and tear it all down. It is a thin client over the\nwire contract: the normative subjects and schemas live in the [SPEC](../SPEC.md); this page is\nlookup material for the commands, not a walkthrough; if you are new, start with\n[Getting started](getting-started.md).\n\n## Running it\n\n```bash\nnpm install -g cotal-ai # puts `cotal` on your PATH (needs Node 22+)\ncotal --help # every command, grouped\ncotal --version # cotal-ai version + each installed extension's (also `cotal -v`)\ncotal <command> --help # one command's flags and usage\n```\n\n`npx cotal-ai <command>` runs it without a global install; in a dev clone, `pnpm cotal <command>`\nruns it through `tsx` with no build step. Bare `cotal` prints help. Every command generates its own\n`--help`, usage, and shell completion from its declared flags.\n\nAn undeclared flag is a usage error, and so is a flag given more than once unless it is\nrepeatable, as `--opt` and `down --session-store` are. The command prints the error and its help,\nexits 1, and does not run.\n\nCommand output, including error lines on stderr and the guided `setup` and `meshes add` prompts,\nis colored only when stdout is a terminal, so piped or redirected output is plain text. A non-empty\n`NO_COLOR` turns color off on a terminal too. `FORCE_COLOR` turns color on even when output is\npiped, unless it is `0` or `false`, and it takes precedence over `NO_COLOR`.\n\nCommands come from the surfaces the binary composes: the base mesh CLI, the manager\n(`supervise`), and the delivery daemon (`deliver`), plus any operator-installed extensions.\n`cotal ext add <npm-package>` installs any registry providers a package contributes: commands,\nruntimes, and local process lifecycle descriptors. The `web` dashboard and optional manager\nruntimes ship this way.\n\n## Commands\n\n| Area | Command | Purpose |\n|---|---|---|\n| Set up & lifecycle | [`setup`](#setup) | Guided, configure-only setup (installs, seeds personas; launches nothing) |\n| Set up & lifecycle | [`update`](#update) | Reconcile first-party extensions and check or opt into a coherent CLI upgrade |\n| Set up & lifecycle | [`up`](#up) | Start a local mesh (nats-server + JetStream), or boot a whole manifest with `-f` |\n| Set up & lifecycle | [`down`](#down) | Stop the whole stack, selected registered components, or a manifest deploy |\n| Set up & lifecycle | [`backup`](#backups) | Create an offline full-space or registry-only artifact from a preserved cut |\n| Set up & lifecycle | [`clean`](#clean) | Configurable cleanup: purge history (live), or wipe the local store / identity (stopped) |\n| Set up & lifecycle | [`meshes`](#mesh-registry) | List the running meshes on this machine |\n| Set up & lifecycle | [`sync`](#mesh-registry) | Refresh the signed-in account's advertised spaces |\n| Set up & lifecycle | [`use`](#mesh-registry) | Set the default mesh a bare `cotal spawn` joins |\n| Set up & lifecycle | [`status`](#mesh-registry) | Read-only diagnostics for setup, processes, and the selected mesh |\n| Agents & personas | [`spawn`](#spawn) | Launch an agent from a persona (foreground, or `--detach` via the manager) |\n| Agents & personas | [`models`](#models) | List connector model catalogs and variants from the manager |\n| Agents & personas | [`ps`](#managed-seats) | List managed agents and their mesh status |\n| Agents & personas | [`stop`](#managed-seats) | Ask the manager to stop a managed agent |\n| Agents & personas | [`attach`](#managed-seats) | Stream and drive a managed agent's terminal (pty runtime) |\n| Agents & personas | [`input`](#input) | Type one line into a managed agent's terminal without attaching |\n| Agents & personas | [`personas`](#personas) | List, show, edit, create, or remove local personas |\n| Agents & personas | [`supervise`](#supervise) | Run a manager daemon (the agent supervisor / control plane) |\n| Agents & personas | [`service`](#service) | Run the manager as a user service (survives logout and reboot) |\n| Agents & personas | [`runtimes`](#runtimes) | List the agent runtimes the manager can spawn through and whether each is reachable |\n| Agents & personas | [`seats`](#seats) | List the pty seat custodians an earlier Linux manager left, and drain the ones whose agent has exited |\n| Agents & personas | [`reconcile-gate`](#reconcile-gate) | Unfreeze an issuance gate left frozen by a crashed restart when the successor cannot boot-heal it (holder gone, complete CONNZ sweep) |\n| Messaging & watching | [`endpoints`](#endpoints) | List every endpoint in the live presence roster, including infrastructure |\n| Messaging & watching | [`describe` / `invoke`](#endpoint-control) | Resolve a v0.4 service's command surface off the wire; invoke one command by name |\n| Messaging & watching | [`send`](#send) | Send one message, then exit: DM a peer, post a channel, or ask a role |\n| Messaging & watching | [`channels`](#channels) | Inspect or set the channel registry |\n| Messaging & watching | [`history`](#history) | Clear retained message history |\n| Messaging & watching | [`console`](#console) | Live protocol view for a space (TUI, or `--plain` line stream) |\n| Messaging & watching | [`web`](#web) | Browser dashboard (installed as the `@cotal-ai/web` extension) |\n| Extensions & misc | [`linear`](#linear) | Serve the official Linear MCP server as an endpoint agents can call (installed as the `@cotal-ai/linear` extension) |\n| Auth & meshes | [`mint`](#mint) | Mint a creds file for a space (static auth mode) |\n| Auth & meshes | [`login`](#login) | Sign in to a per-user-auth mesh's IdP (once per machine) |\n| Auth & meshes | [`logout`](#login) | Revoke the IdP session and clear the cached login |\n| Auth & meshes | [`actor`](#actor) | Manage a user-auth space's actor ledger (grant / revoke / list) |\n| Auth & meshes | [`doctor`](#doctor) | Credential-health diagnosis and repair (`doctor auth`) |\n| Auth & meshes | [`join`](#join) | Join a space as your own presence (interactive) |\n| Manifest | [`topology`](#manifest-deploys) | Validate and view a mesh manifest's access graph (read-only) |\n| Extensions & misc | [`ext`](#ext) | Install / remove operator CLI extensions |\n| Extensions & misc | [`completion`](#completion) | Print or install shell completion |\n| Extensions & misc | [`feedback`](#feedback) | Send feedback to the Cotal developers |\n| Extensions & misc | [`deliver`](#server-daemons) | Run the server-side Plane-3 delivery daemon |\n| Workflow runs | [`run`](#run) | Operate durable workflow runs: start, resume, list, inspect, answer a checkpoint, check an edited program with migrate |\n| Extensions & misc | [`feedback-intake`](#server-daemons) | Run a self-hosted feedback intake server |\n\nThe manifest modes of `up`, `spawn`, and `down` (`-f <cotal.yaml>`) plus `topology` are covered\ntogether under [Manifest deploys](#manifest-deploys).\n\n## setup\n\n```bash\ncotal setup [--full] [--demo] [--yes] [--skills]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--full` | off | Redo the full guided flow (implies `--demo`) |\n| `--demo` | off | Also seed the guided expert team (`david`, `sven`, `me`) |\n| `--yes`, `-y` | off | Non-interactive accept-all (for agents / CI) |\n| `--skills` | off | Reconcile Cotal skills only through installed connector providers, plus `~/.agents/skills`. Refused with `--full` or `--demo`. |\n\nGuided setup is **configure-only**: it checks prerequisites, invokes installed connectors' declared setup providers, and\nseeds persona files, and it launches nothing (no mesh, no web, no manager). First run gets the\nnarrated flow; later runs print a status card. By default it seeds one `default` persona; the\n`david`/`sven`/`me` team is opt-in via `--demo`. `cotal status` points stale Claude skills and\nout-of-date `.agents` skills at `cotal setup --skills`, not unscoped `setup`. See [Getting started](getting-started.md) and, for\nmaintainers, [setup internals](setup-internals.md).\n\nWhen a mesh resolves, setup seeds that mesh's recorded `.cotal/agents` catalog, the same catalog a\nfollowing `cotal spawn` reads. It prints the absolute destination. On a fresh machine with no mesh it\nuses this folder and says why; when several meshes are available and none is selected, it refuses\nrather than choosing a catalog.\n\n## update\n\n```bash\ncotal update [--self] [--space <s>] [--server <url>] [--creds <path>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--self` | off | If a newer release exists, install that exact validated `cotal-ai` version globally and reconcile through the newly installed binary |\n| `--space`, `--server`, `--creds` | resolved mesh | Select the running manager whose continuity state is reported |\n\nWithout `--self`, `update` keeps the installed first-party surfaces coherent with the running\nbinary: it force-reconciles the four built-in connectors, then reinstalls other `@cotal-ai/*`\noperator extensions at the binary's exact version. Each extension runs in an isolated child, so one\nfailure cannot poison later replays. It then checks npm; a newer binary is an informational notice\nwith `cotal update --self` as the next command, not an automatic install.\n\nAfter disk reconciliation, `update` reads the selected running manager. A machine with no recorded\nmesh has no running manager to observe, so that read is skipped and the command completes. The same\nholds when every recorded mesh is down and none is selected. A remote user mesh, registered with\n`cotal meshes add --mode user`, is named and skipped: its manager runs under another install, so\nthere is no custody on this machine to preserve, and a `legacy` verdict still comes only from a\nmanager this machine read. A recorded mesh that is down is still a\nrefusal when the command selects it, with `--space` or by running inside its project, and so is a\nnamed space that is not running. With several meshes running and no `--space`, `--server` or\n`--creds`, the install is machine-wide, so every running manager is reported in turn, each under its\nspace name, before anything is written; a `legacy` verdict on any of them makes the whole run not a\nhot update. A selector flag still reports one manager. A manager without a\ncustody generation is reported as `legacy`: it cannot preserve its manager-owned PTYs, so the\ncommand says that this is not a hot update and prints `exact`, `fork`, `fresh`, or `drain-only`\nfor every seat. This report sends no stop, preservation-commit, or replacement command.\nIt does not preserve a running PTY on a legacy manager. The built-in pty runtime spawns\nin-process on every platform and reports `legacy`. On Linux it still adopts seats that an earlier\nmanager left under a detached custodian, but it starts no new custodian. An incompatible native\n`@lydell/node-pty` or ConPTY ABI break remains an explicit per-seat maintenance cut.\n\nWith `--self`, the selected running manager is reported before any global install. When a newer\nrelease exists, Cotal then installs the exact version it validated, resolves and verifies that\npackage in npm's global root, then launches that binary with the same `--space` / `--server` /\n`--creds` selection to reconcile connectors and first-party extensions to the new generation. An npx\nor dev-clone invocation therefore installs and continues through a separate global copy; it never\nclaims the already-running process changed. If the binary is current, `--self` performs the normal\nlocal reconcile without reinstalling it.\n\nThird-party extensions are listed with their installed version and recorded spec but are not\nauto-updated in v1. Floating third-party updates require `@cotal-ai/*` peer-range validation and are\na future follow-up. A failed connector/extension install, npm metadata check, or requested global\ninstall is reported and makes the command exit nonzero. Independent extension attempts continue so\nthe output includes every failure; an unavailable npm registry does not undo a completed local\nreconcile, but the command still exits nonzero because it could not establish that the install is\ncurrent.\n\n## up\n\n```bash\ncotal up [--detach] [--open] [--space <s>] [--server <url>] [--channels <path>] [--runtime <name>]\ncotal up --user-auth --idp <url> [--exchange-public-port <n> --exchange-public-url <https://\u2026> [--exchange-trusted-proxy]]\ncotal up --tls-cert <cert.pem> --tls-key <key.pem> # serve broker TLS (both, or neither)\ncotal up --restore <dir> [--restore-only registry] [--accept-missing-source]\ncotal up -f <cotal.yaml> [--dry-run] [--runtime <name>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--server <url>` | auto (free local port) | Listen URL override |\n| `--host <host>` | none | Bind host override for a **fresh** broker boot: an IP or hostname only, never a URL (that is `--server`) and never `host:port` (the port comes from `--server` or its default); a URL or port-bearing value is refused pointing at the right flag. With no `--server`, the broker URL is derived from it, so `--host <addr>` alone is enough to make a mesh reachable at that address; a `--host`/`--server` pair naming different addresses is refused. A wildcard bind (`0.0.0.0`, `::`) keeps a dialable loopback URL. Recorded on the mesh and reused by every later manager launch, so a repair or resume keeps remote [`attach`](#managed-seats) working. A live refresh (`\u2713 mesh already running`) does not rewrite `.cotal/auth/server.conf` or rebind nats; stop the broker, then re-run `up --host` |\n| `--space <s>` | the folder's name | Space name |\n| `--store-dir <dir>` | none | JetStream store directory (recorded; a repair up reuses it) |\n| `--max-file-store <bytes>` | nats-server's dynamic cap | JetStream file storage cap in bytes (a positive integer, no unit suffix). Without it nats-server sizes the store at start as three quarters of the free space on its filesystem. The cap is fixed at broker start: a running broker cannot change it (`cotal down` first), `down --preserve-state` keeps it for the resume, and a resume with a different value is refused. Not accepted with `-f` |\n| `--channels <path>` | `.cotal/channels.json` if present | Channel-registry seed file (JSON). An explicit path that is missing is an error |\n| `--restore <dir>` | none | Restore a completed offline backup before exposing the normal listener |\n| `--restore-only registry` | artifact selection | Restore only the registry component |\n| `--accept-missing-source` | off | Explicit disaster consent when the inode-bound preserved source is absent |\n| `--accept-stale-checkpoint` | off | Explicit consent to resume a seat whose checkpoint was captured outside its recorded recency horizon |\n| `--open` | off (auth) | Unauthenticated dev mesh: no JWT, no ACLs |\n| `--user-auth` | off | Per-user auth: people `cotal login`; connects are authorized against the actor ledger |\n| `--idp <url>` | none | With `--user-auth`: the IdP auth base URL to pin on first enable |\n| `--exchange-public-port <n>` | none | With `--user-auth`: add the public exchange face on this loopback port, for an HTTPS reverse proxy to forward to |\n| `--exchange-public-url <https://\u2026>` | none | With `--exchange-public-port`: advertise the reverse proxy's HTTPS URL in discovery |\n| `--exchange-trusted-proxy` | off | With `--exchange-public-port`: attribute public failure buckets to the last `X-Forwarded-For` hop. Enable only when the listener is reachable solely through a trusted proxy; otherwise the socket address is used |\n| `--detach` | off | Run in the background (stop with `cotal down`) |\n| `--tls-cert <path>` | none | PEM certificate to serve TLS with. Must be given together with `--tls-key`. Before starting the broker, Cotal checks readability, private-key mode, key/certificate match, the validity window, and host coverage. `nats-server` accepts an expired certificate and leaves the failure to clients, so Cotal performs these checks first. The decision is recorded; a later bare `cotal up` keeps serving TLS |\n| `--tls-key <path>` | none | PEM private key for `--tls-cert`. Refused if group- or other-readable (tighten to `600`) |\n| `--file <cotal.yaml>`, `-f` | none | Launch a whole mesh from a manifest |\n| `--dry-run` | off | With `-f`: print the plan, mutate nothing |\n| `--runtime <name>` | `pty` (or the manifest's, with `-f`) | Agent runtime for the mesh manager (`pty` built in; others are installed extensions, explicit-only). Resolved + probed before the broker starts; an uninstalled/unreachable runtime fails loud. With `-f`, overrides the manifest's runtime |\n| `--max-sessions <n>` | 64 | Live-session ceiling for the mesh manager. Each console pane and each `cotal attach` is one session, so size for agents \xD7 panes, not agent count. Recorded on the mesh and reused by every later manager launch, so a repair or resume does not silently drop back to 64. A running manager cannot change it: `cotal down` first, then `cotal up --max-sessions <n>` |\n| `--no-manager` | off | Broker-only boot: start the broker and, in auth mode, the delivery daemon, and no local manager. A refresh under the flag of a mesh whose manager is live refuses rather than keeping or stopping it (`cotal down manager` first). Cannot be combined with `--runtime`, `--max-sessions`, or an agent-declaring manifest |\n| `--rotate-sys` | off | Rotate the space's system account and re-mint its two `$SYS` creds. Needs a stopped mesh; refused with `--open` |\n\n`cotal up` boots a local nats-server with JetStream and, in auth mode (the default), JWT auth and\nper-agent ACLs; `--detach` records the mesh so `cotal spawn` from any directory can find it. With no\n`--server`, it auto-selects a free port if the default address is taken; an explicit `--server`\nstays fail-loud on collision. `--detach` also brings up the control plane (delivery daemon in auth\nmode, then the manager). `--no-manager` is the broker-only mode: it boots\nthe broker (and the delivery daemon in auth mode) and starts no manager, so there is no manager\npidfile to leave stale. A refresh under the flag of a mesh whose manager is live refuses rather\nthan keeping or stopping it: `cotal down manager` first. For a split topology with a manager, wait for `.cotal/manager.<spaceKey>.log` to contain `\u2713 manager up`, then `cotal down manager` on that\nhost and run [`supervise`](#supervise) against the remote broker; see\n[Run a mesh](run-a-mesh.md). `cotal up --detach` prints `\u2713 running in the background:` with\n`manager` listed (pidfile liveness, not a teardown boundary); with `--no-manager` the line lists\nonly what actually started. Ctrl-C on a foreground `up` stops the manager through the same stop as\nbare `cotal down` (see [`down`](#down)), then the rest of the stack, and reports managed agents\nunder the same rule: when the manager stop is refused, Ctrl-C prints the refusal with the reap route\nand leaves the stack running. The `-f` form is a\n[manifest deploy](#manifest-deploys).\n\nA repair `up` on a mesh whose broker died reopens the store its record names, and refuses a\ndifferent `--store-dir` rather than silently opening a second store.\n\nThe generated `.cotal/auth/server.conf` is written on a real broker boot and is not an\noperator-owned config. `--host` changes that file only when nats is actually started. A unit\nrestart that leaves an answering listener in place is a refresh, not a rebind.\n\nOn an existing mesh, `cotal up` reconciles the presence and lease bucket TTLs. It writes a reserved\ncanary and waits for the bucket to expire it before reporting success. If the broker accepts the\nstream update but the backing store does not persist or enforce it, `up` exits nonzero with a TTL\npersistence error instead of trusting the value returned by stream info. A refresh that restores a\nmissing manager says so with its pid (`\u2713 restored in the background: manager (pid N)`); a refresh\nthat finds everything already running prints only the `\u2713 mesh \"<space>\" already running` line. A\nfirst boot starts its manager without the restore line.\n\n\n`--user-auth --idp <url>` starts the space's auth service alongside the broker: the NATS\nauth callout plus its capability-gated local exchange, and optionally the closed public exchange\nface configured by the three `--exchange-*` flags above. The service is torn down with `cotal down`,\nand a re-run of `cotal up` heals a dead service on a running broker. `up` waits for the service to\nfinish binding: while the daemon it launched (or found running) stays alive, the wait extends past\nthe base 15s up to 60s; a daemon that exits is refused at once with \"exited before becoming ready\",\nand one alive past 60s is refused as \"alive and still starting\" (wedged), naming the pid record and\nthe service log. `--user-auth` and `--open`\ncontradict each other and are refused loudly; a running broker cannot change auth mode\nwithout a `cotal down` first. See [identity & auth](identity-and-auth.md).\n\n`--rotate-sys` renews the two `$SYS` credentials (`membership-observer`, `connection-evictor`).\nThey carry a 30-day expiry and nothing re-signs them in place, because the system-account seed is\nnever persisted, so they are renewed by issuing a **new system account** under the same broker\noperator and minting fresh creds against it. A plain re-`up` does **not** do this: it reuses the\nexisting trust record, and its `$SYS` creds along with it.\n\nThe rotation is safe to run on a real space, with one operational cost. The data account, the account\nsigning key, every agent credential minted from it, and the JetStream store are all untouched; what\ndies is the retired system account, and with it any out-of-band copy of the old `$SYS` creds, on every\nbroker that loads the rotated config. The cost is that **earlier full backups stop being restorable**\n(see below), so this is not a no-consequence operation. It needs the broker to restart on the rewritten\nconfig, so it runs as part of a boot:\n\n```bash\ncotal down\ncotal up --rotate-sys --detach # agents reconnect; nothing is re-provisioned\ncotal doctor auth # both $SYS creds healthy again, 30 days out\n```\n\nA rotation is a stopped, fresh boot, and anything that is not one refuses it, all for the same reason\n(the on-disk material and the broker it runs on must never end up on different generations):\n\n- a live mesh, because the running broker would keep serving the retired account;\n- an open mesh, whether that comes from `--open` or from `broker.auth: false` in a manifest, which\n has no system account at all;\n- `--restore`, because reinstating a trust root and superseding it in one command leaves no way to\n say which authority the mesh came up on;\n- an unfinished restore or resume attempt on this root, including one `cotal up` would recover on\n its own, because those paths can adopt a live listener and return without booting a broker;\n- a root that hosts more than one space, because the system account lives in the shared broker\n record and a rotation would retire every tenant's, while the root holds one `$SYS` cred pair\n pinned to one data account.\n\nTwo things to know before you run it:\n\n- **The retirement is config-load-bound.** Old `$SYS` creds are refused by any broker that loads the\n rotated config. A stale `nats-server` still running the *previous* config in memory would keep\n honouring them, so stop every broker for this root first. `--rotate-sys` refuses if this root's\n mesh is recorded as running, if anything unidentified is answering at the address it was given, or\n if the root's pid file names a live (or unreadable) process. Those are Cotal's own ownership\n records, not a scan of the process table: a `nats-server` you started by hand against this root's\n `server.conf` on some other port writes none of them and will not be seen. Do not run one.\n- **It invalidates earlier full backups.** A full artifact binds to the trust chain it was taken\n against, and that commitment covers the operator JWT and the system account. Every full backup\n taken before a rotation refuses to restore afterwards, so take a fresh `cotal backup` once the\n rotated mesh is up. `cotal up --restore` names this case when the data account still matches.\n\nThe commit is not atomic (a trust-record write plus two credential writes), so an interrupted\nrotation leaves the record ahead of the creds. That split is detected rather than silent: every\n`cotal up` on an auth mesh, and every `cotal doctor auth`, compares each `$SYS` cred's issuer against\nthe persisted record and names the retired account. `up` warns rather than refusing, because these\ncreds power the membership graph and live eviction, both of which degrade fail-soft; the mesh is not\nworth taking down over them. Re-running the rotation heals it, at the cost of one generation.\n\nWhile those creds are expired the mesh keeps delivering messages, but the\n[membership feed](delivery-daemon.md) and live connection eviction stay down; `cotal doctor auth`\nand the manager's log both name the credential and this repair.\n\n## down\n\n```bash\ncotal down\ncotal down --with-agents\ncotal down --preserve-state [--store-dir <dir>] [--session-store <dir> \u2026]\ncotal down manager [delivery auth web nats ...]\ncotal down web [--space <name>]\ncotal down -f <cotal.yaml> | --run <id> [--dry-run]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--file <cotal.yaml>`, `-f` | none | Tear down this manifest's deploy |\n| `--run <id>` | none | Tear down one `spawn -f` run by id |\n| `--space <name>` | current mesh | With components: the mesh whose target-addressed components (e.g. `web`) to stop |\n| `--dry-run` | off | Print the manifest teardown or selected components, mutate nothing |\n| `--with-agents` | off | Bare whole stack only: also stop and deprovision every managed agent |\n| `--preserve-state` | off | Bare whole stack only: fence the manager, retain principals and durable state, stop and prove the stack down, then publish `ready` |\n| `--store-dir <dir>` | `.cotal/nats` | With `--preserve-state`: the actual store path (required for a custom store) |\n| `--session-store <dir>` | none | With `--preserve-state`: a harness transcript store directory to capture with every continuation-capable retained seat. Repeatable. No default and never inferred from a connector name; a path that does not exist or is not a directory is refused before anything stops |\n\nBare `cotal down` stops the whole local stack in dependency order and leaves managed agents running\nwhen their runtime lets them outlive the manager. Before signalling the manager it verifies the spare\ncapability of the exact recorded manager, which records what that manager's stop does with its\nseats, and it reports the agents left behind plus `cotal down --with-agents` as the explicit reap.\nWhen the manager had no managed agents, it prints no report.\nThe built-in pty runtime keeps each PTY inside the manager process, so those seats cannot outlive\nit: every manager stop stops and deprovisions them, and `down` reports them as stopped. Every manager\nstop the CLI makes runs this one path: `down`, Ctrl-C on a foreground `cotal up`, the teardown after\nthat `up`'s broker exits, the leftover-manager stop before `cotal up -f`, and the delivery cutover.\nEach holds the manager's stop reservation, so a second stop while one is in flight is refused,\nnames the process holding it, and leaves that stop's `--with-agents` policy in place. Each sends\n`SIGKILL` to a manager still running 15s after `SIGTERM`. Ctrl-C stops the manager first; when that\nstop is refused or the manager's exit cannot be confirmed, Ctrl-C signals nothing else, prints the\nrefusal with the reap route, and leaves the stack running; end it with `cotal down --with-agents`.\n`--with-agents` is a one-shot destructive policy bound to the exact verified manager process\nand the exact live `down` stop reservation; a stale, malformed, crashed, or different stop attempt\ncannot turn a later bare shutdown destructive. If a managed agent cannot be proven stopped within\nthe manager's stop timeout, the manager logs which one, still closes its broker connections and\nconsole listener, and exits with code 1. It does not release its pidfile, liveness lease or\nservice registration in that case, so no successor is handed authority while that agent may still\nrun; the lease lapses on its TTL. Positional component names stop\nonly those self-registered local processes; for example, `cotal down manager` leaves delivery and\nthe broker running, and `cotal down web` is available when the web extension is installed. A\ncomponent that starts target-resolved (the web dashboard) is stopped the same way: `cotal down web`\nresolves the mesh the same way as `cotal web` (registry current mesh first, `--space` to name one), so\nit works from any directory; the other components always stop under the folder you run it in. The\n`-f` / `--run` forms tear down a [manifest deploy](#manifest-deploys) without stopping the whole mesh\nand cannot be combined with component names. Stopping `nats` alone is refused while an unselected\nregistered daemon is still live; include those components or use bare `cotal down`.\n\nA pinned manager with no spare-capability record is not signalled by bare `cotal down` or `cotal\ndown manager`. A current manager always publishes the record, so a missing one means an older\nmanager: one that predates capability reporting, or one whose pty runtime reported that it cannot\ndetach its agents. Stop each managed agent explicitly, then run `cotal down --with-agents` from the\nmesh root to stop the whole stack. An older manager does not understand\nthe reap request, which is why the agents must already be stopped.\n\nBare `cotal down` inventories by pidfile. When this folder's registered broker answers and no\n`nats.pid` records it, the command does not say nothing is running. It names the space and the\nbroker address, says no pidfile records that process, says it will not stop a process it did not\nstart, and exits 1. Stop that broker with whatever started it (an init unit, a container, or the\nhand-run process). `cotal meshes rm <space>` only drops the registration. The probe runs whether or\nnot other owned components were running: they stop and clear their artifacts first, then the broker\nis named. A component stop and `--dry-run` stay pidfile-only and do not probe.\n\n`down` reads each process record once. A component that exits and removes its own record while\n`down` runs counts as having no record, so the stop goes on. Any other failed read is an error.\n\n**Teardown verifies pinned process identity before signalling.** PIDs are recycled by every OS,\nso a recorded pid alone is not a durable target identity. `up` and `cotal web` record each\nprocess's creation identity in a sibling `<pidfile>.identity` pin, which holds the pid and the\nprocess start reported by the OS. Every stop path, including `down` for the broker, web and\nextension components, and the manager, delivery and auth-service stops, applies the same rule. A pin\nthat names a different start means the pid was reused, so teardown refuses and preserves it. A torn\nor unreadable pin also refuses.\n\nThe pidfile and its pin are published by renames, and the pidfile rename is the commit point. Just\nbefore it, the pin holds two lines: the old process's and the new one's. A launcher that dies\nmid-publish therefore leaves the old record or the new one, each checked against its own pin line,\nnever a pidfile without its pin. An old record with no pin is legacy, so its line holds `-` in place\nof the token and it stays legacy until the commit. A CLI older than this change reads a two-line pin\nas torn and refuses.\n\nPublishes of one pidfile are serialized by a lock file beside it, `<pidfile>.publish.lock`, because\nthe launcher and the daemon it starts both publish the same record. The next publisher reclaims a\nlock left by a crashed one. When no start token can be read for the new process, its pin line holds\n`-` in place of the token, which reads as a legacy record, and the publish ends in the legacy shape:\na pidfile with no pin. Teardown, and a daemon removing its own record on exit, take the same lock and\nremove the record only while the pidfile still names the pid they stopped, so a stop that races a\npublish leaves the new record whole.\n\nThe web dashboard claims `web.pid` with an exclusive create, so a second dashboard for the same mesh\nis refused, and writes its pin right after the claim. A stop that runs between the two reads a\nlegacy record. A refused claim calls the record stale, and points at `cotal down web`, only when the\nfile is empty or its pid is proven gone. Content that is not a pid, or a pid whose liveness the\nkernel will not report, is refused as possibly fronting a running process, as `cotal down web`\nrefuses it.\n\nThe pidfile pid and the pin pid are two coordinates. Automatic cleanup follows **proven death of\nthe pidfile target** (ESRCH on that pid): a torn sibling pin does not wedge a dead pidfile pid.\nA torn pairing where the pin names another pid, while the pidfile pid is still live or not proven\ndead, still refuses. Inspect both pids with `ps`. Do not delete `<pidfile>.identity` to force a\nstop; that weakens target-identity protection. Once the pidfile process is dead, rerunning\nteardown clears the stale record automatically.\n\nThe first teardown after upgrading a running pre-pin stack has a narrower guarantee. A live record\nwith no identity pin is signalled after a loud warning that it predates identity pinning. Restarting\nthe component writes the pin, so later teardowns receive full match and mismatch protection. The\nsame warning applies on platforms where no stable start token is available. For a legacy manager,\nbare `cotal down` also warns that agent sparing cannot be verified before it signals. Because the\nCLI cannot establish which SIGTERM handler that already-running binary carries, it never presents\nthe pre-signal seat inventory as confirmed spared; a genuinely older destructive handler may still\nreap those agents. `--with-agents` publishes a one-shot reduced-guarantee handoff bound to the\nrecorded manager pid and the live `.stopping` reservation's inode, then signals unconditionally.\nThat handoff cannot be replayed by a later stop attempt. A pin that exists and does not match the\nlive process still refuses before signal.\n\n`--with-agents` performs the old destructive logical teardown: managed processes stop and their\ncredentials, ACL rows, and delivery footprints are deprovisioned. `--preserve-state` is a different\nmaintenance transition: it stops retained processes while suppressing leave/deprovision cleanup, persists the manager's\nsame-principal resume inventory, stops the entire stack without removing run/auth artifacts, and\npublishes a stable inode-bound cut only after every recorded process is proven stopped and the exact\nrecorded NATS endpoint is unreachable. A missing or stale broker pidfile never counts as stopped. The\nattempt is bound durably before the manager is fenced, the resume document and attempt-bound\n`cut-intent` are fsynced before manager commit, and the manager's commitment itself is journaled\n(`cut-committed`) before any process stops. A retry after a crash at any of those boundaries reuses\nthe exact recorded attempt and finishes the remaining stop and endpoint proofs idempotently, without\nneeding the (by then intentionally dead) manager. A partial cut never publishes `ready`. It cannot\nbe combined with component names, manifest teardown, or `--dry-run`.\n\n**Seat checkpoints.** After the stack is proven down, the cut writes one checkpoint per retained\nseat under `.cotal/maintenance/v1/checkpoints/<attempt>/<seat>/`, and prints the path, the\ncontinuity class and the generation for each. The path carries the preservation attempt because a\ncheckpoint is immutable once sealed: a shared directory would make the second cut in a root refuse\non the first cut's leftovers, and clearing it would destroy an artifact a rollback still needs. The\ncapture happens only at that point because anything earlier races a harness that is still writing\nits transcript and its working tree.\n\nEach checkpoint directory is created 0700, refuses a destination that already exists, and holds:\n\n- `repo.bundle`, the seat `cwd`'s reachable history, anchored on the base commit the record names\n by full object id;\n- `repo.index.diff` and `repo.worktree.diff`, the staging state as two diffs, base to index and\n index to worktree. Two rather than one because a single combined diff restores a mixed tree with\n the right bytes and the wrong index: a source reporting `MM README` would come back as ` M README`;\n- `repo.untracked.tar`, the untracked files in scope;\n- the harness session pointer, when the seat's connector declares one, and the transcript store\n files the operator named with `--session-store`. Each records where the destination puts it back\n as an anchor (the workspace root, the account home, or the seat's `cwd`) plus a relative path,\n because the destination's root and home are its own and the source host's absolute spelling would\n either miss them or write outside them;\n- `checkpoint.json`, written last, after every digest is computed over the bytes that landed.\n\nThe record carries the manager's resume entry unchanged as its first field, then the space, the seat\nname, the recovered `lifecycleUid`, the writer generation the cut was taken at, `capturedAt`, the\nrecency horizon, the applied profile revision, the seat's `git status --porcelain` as the cut read\nit, and the continuity class. Every captured file is\nlisted with its byte size and sha256, so an operator verifies the whole artifact with `sha256sum`\nand `git bundle verify`. No secret values, no operator keys and no source-host launch material\nenter it.\n\nThe generation is the highest one this root has claimed for the seat in `.cotal/auth`. A\ngeneration entry there that is a symlink, a directory or anything else that is not a regular file\nrefuses the cut, as every other auth record does.\n\nThe continuity class is what the connector declares, capped by what the checkpoint carries. A\nconnector declaring session continuation classifies as `exact`, but reopening a session takes both\nhalves, the pointer that names it and the store that holds its transcript. A checkpoint missing\neither one cannot reopen that session, so it is recorded as `fresh` when the connector declares a\nfresh start and `drain-only` otherwise. A pointer with no store is capped the same way as a cut\ncarrying neither, because it names a session whose bytes the artifact does not contain. A class is a promise the destination is entitled\nto act on, so it never describes bytes the artifact does not contain. The transcript store stays an\noperator input: this repository does not know where a harness keeps its transcript, so `exact`\nrequires `--session-store` to name one.\n\nThe recorded status is read under the same selection rule as the untracked set, so it describes the\nstate the captured bytes can reproduce. The destination re-reads it in the promoted tree and refuses\na difference.\n\nThe untracked selection rule is recorded in the record and is\n`git ls-files --others --exclude-standard -z, excluding .cotal/`. It honors `.gitignore`, so an\nignored file the seat needs does not travel and has to be moved separately. The `.cotal/` exclusion\nis a secrecy boundary rather than a size one: when a seat's `cwd` is also the mesh root, the control\ndirectory is untracked, and without the exclusion the broker trust material, the space account, the\nmanager instance identity's private seed and the seat's own credentials would land inside the\nartifact. A checkpoint carries credential references only; the destination resolves that material\nitself.\n\nA seat whose launch options could not be resolved is refused rather than checkpointed, with the\nmanager's own wording: `imperative launch options have no non-secret durable source (<keys>)`. The\nrefusal arrives at prepare time, so the cut stops before any child does.\n\nA delegated seat (SPEC \xA713.17) is refused at prepare time too, with\n`a delegated seat is not resumed by a later manager; stop it before preserving`. A manager stop\nafter a refused cut retires that seat through its retirement path.\n\n## clean\n\n```bash\ncotal clean <history|store|all> --force\ncotal clean restore-attempt --attempt <id> --force\ncotal clean restore-fallback --attempt <id> --force\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | `history`: target mesh |\n| `--dms` | off | `history`: also clear DM history |\n| `--store-dir <dir>` | `.cotal/nats` | `store`/`all`: JetStream store directory |\n| `--force` | none | Required: destructive, no prompting |\n| `--attempt <id>` | none | `restore-attempt`: exact stale pre-commit attempt; `restore-fallback`: matching healthy committed restore |\n\nOne configurable cleanup verb; every target requires `--force`.\n\n- `history` purges the retained message backlog on the **running** broker (channels, plus DMs\n with `--dms`). The same operation as [`history clear`](#history), which stays as an alias.\n- `store` deletes the **stopped** mesh's JetStream store (`.cotal/nats`): streams, durable\n consumers, and messages. This is the reset for stale on-disk broker state, e.g. durables\n minted by an older, incompatible Cotal generation surviving a `down`/`up` cycle.\n- `all` is `store` plus the space identity (`.cotal/auth`), the local creds and markers tied to\n it, any crash residue a normal `down` would have swept (stale pidfiles, `run/`), and the mesh's\n registry entry; the next `cotal up` mints a fresh identity.\n\n`history` needs the mesh up; `store` and `all` refuse while any recorded mesh process is still\nalive or any same-root recorded broker endpoint remains reachable (run `cotal down` first). They\nalso refuse outright on a root that holds accounts for several spaces: the store and the broker\ntrust record are shared by every space on the broker, so both targets would take out all of them\nand no `--space` can narrow that. `down`, `backup` and `up --restore` refuse there for the same\nreason. `cotal status` lists the tenants on such a root. Personas\n(`.cotal/agents`) and logs are never touched. The mesh record now carries a custom\nstore location for `up`'s own repair, but `clean` still takes `--store-dir` itself; `clean` does\nnot read the record. Custom cleanup targets must contain either the Cotal store-generation marker or a\nreal `jetstream/` store directory; filesystem roots, project roots, and Cotal auth/maintenance trees\nare always refused.\n\n`store` and `all` also refuse every maintenance journal state. After a healthy committed restore,\n`restore-fallback` is the only supported way to remove the recorded unchanged old-store inode; it\nnever deletes the active target, requires both the exact attempt id and `--force`, and retires the\ncompleted restore journal so a later `down --preserve-state` can start a new backup cycle.\n\n## Backups\n\n```bash\ncotal down --preserve-state [--store-dir <dir>]\ncotal backup create <dir> [--only full|registry] [--store-dir <dir>]\ncotal up --restore <dir> [--restore-only registry] [--accept-missing-source]\n```\n\nBackup is offline-only. It requires the stable `ready` record from `down --preserve-state`, an exact\nstore match, no live recorded process, and an unreachable exact endpoint from the recorded cut.\nThat endpoint is probed immediately before cloning, so a live broker with a missing or stale pidfile\nis still refused. It claims the cut, reflink/copies the stopped source to a\nprivate attempt clone, and opens only that clone on a random loopback bootstrap broker with an\nindependent parent/deadline watchdog. It validates the canonical stream and pull-consumer inventory,\nwrites native snapshots with consumers excluded, and stores conservative contiguous ACK-floor\ncheckpoints separately. The presence bucket is memory-backed, so it does not survive the cut and\nthe clone may lack it. Every other stream must be present. The original store is never opened by\nthe backup broker, and the stack is not restarted implicitly. Artifact destinations must not overlap\nthe preserved source or maintenance\nattempt tree. Restore artifacts and targets likewise cannot nest inside or contain each other, the\npreserved source, or the maintenance attempt tree.\n\nStopped client-managed KV ordered consumers are ephemeral read residue, not backup state. Backup\nignores only the pinned client's exact stopped shapes: ordinary last-value watchers and the\nwhole-bucket scanner that uses all-history delivery to collapse concurrent tombstones. A bound\nconsumer or any lookalike with a different filter, inbox, lifetime, or other config is still refused.\n\n`full` is the default and indivisible: channel registry, CHAT/DM/TASK/INBOX/DLV, ACL, MEMBERS, and\nvalidated durable checkpoints. `registry` is the sole partial artifact. Presence, derived membership\nfeed, leases, native ephemeral/history consumers, credentials, keys, tokens, owner secrets, and actor\nledger files are excluded. `full` means every transferable message and registry stream, not every\nJetStream resource: endpoint submissions/facts/events/timers/workflow state, contract artifacts, and\nthe records/auth/session stores are nonportable control state. Restore recreates those streams empty\nwith their canonical configs before exposing the normal listener, so active endpoint runs,\nlifecycles, and sessions do not cross a backup. Artifacts are exclusively created `0700`;\nsnapshot/checkpoint files and\nthe manifest are `0600`; `manifest.json` is written last with exact sizes and SHA-256 values. The\ndirectory is trusted operator input: hashes detect corruption, not malicious rewriting.\n\nRestore validates and stages the exact allowlisted artifact bytes before moving or creating a store.\nIt requires the same space and existing trust state. The whole pre-commit window holds a journaled\nliveness claim (coordinator, watchdogs, brokers, absolute deadline): ordinary `up` and a repeated\n`up --restore` refuse while the claim is live, and a stale attempt is recovered only after the\ndeadline has elapsed and every recorded owner is proven dead. A retried `up --restore` handles this\nautomatically; an operator can also recover it explicitly with `cotal clean restore-attempt --attempt <id> --force`. Nothing\never rolls back a live attempt. A registry-only artifact restores as registry-only whether or not\n`--restore-only registry` is passed; omitted infrastructure is always created and the exact\npost-restore stream inventory is asserted before commit intent. Ordinary `up` from a preserved cut\nresumes only the exact recorded source store and runtime; a contradicting `--store-dir` or\n`--runtime` fails in preflight.\n\n**Admitting a seat checkpoint.** An ordinary `up` from a preserved cut admits that cut's seat\ncheckpoints before it journals the resume attempt and before any process starts, so a refusal costs\nnothing. Three gates run in order, each naming what it saw.\n\n1. *Integrity.* Every file the record names must be present, a regular non-symlink file, the\n recorded byte size and the recorded sha256, re-stat'd after the read so a file that moved is a\n refusal. Failure here consults no other gate.\n2. *Identity.* The recorded space must match, the recorded `lifecycleUid` must not belong to a live\n incarnation, and the profile revision must match this host's or be resumed under deliberately\n this host's. A differing revision is refused with both digests and the remedy, and there is no\n override: the checkpoint carries the recorded digest and not the config bytes, so nothing could\n run the seat under the recorded revision, and the manager re-digests the same file and refuses\n drift on its own. This gate has no blanket override, which is the only reason the next one may\n have one.\n3. *Recency.* `capturedAt` is compared to this host's clock against the horizon the record carries.\n Inside it, the seat resumes. Outside it, `up` refuses and prints the capture instant, the clock\n reading and the horizon; `--accept-stale-checkpoint` admits it anyway and the exercised consent\n is printed with the actual age. An unreadable `capturedAt` is refused with no override, because a\n freshness gate that fails open is not a gate.\n\nCustody transfers only after all three pass. The destination claims the recorded generation plus one\nby exclusive create, before it launches anything. A lost create means another destination is already\nclaiming that seat, and it refuses with `seat-writer-generation-create-lost` rather than adopting\nthe winner and becoming a second writer. The recorded `lifecycleUid` is reused and never minted, so\nthe resumed seat binds the same lifecycle-keyed durables.\n\nAdmission is reconciled against the inventory the resume is about to hand the manager, and that\nreconciliation finishes before the restore moves a single tree. A checkpoint whose recorded\n`lifecycleUid` is not the one the retained inventory carries describes a different incarnation of\nthat seat, and it refuses with both uids while every live working tree is still untouched and no\ngeneration is claimed. A retained\nseat with no admitted checkpoint refuses the resume by name: an absent checkpoint directory and an\nabsent record are indistinguishable from a seat that was never checkpointed, and a seat that starts\nwithout passing the gates has claimed no generation. `--accept-stale-checkpoint` is recorded in the\nresume journal with the seat, the capture instant, the admitted age and the horizon, so the consent\nsurvives the terminal it was typed into.\n\nThe whole admission is all or nothing. Coverage is settled first, then every gate runs over every\ncheckpoint, and only then is any generation claimed. A refusal at any point leaves every generation\nunclaimed, including a lost exclusive create during the claim itself: the claims that attempt made\nare removed before the refusal is raised, by the exact paths it wrote, so a generation another\ndestination holds is never touched. A claim is a create that can never be made again, so a refusal\nthat left one behind would consume the retry over the same checkpoint set.\n\n**Restoring a seat checkpoint.** Once every gate has passed over every checkpoint, and before a\nsingle generation is claimed, `up` puts each admitted seat's captured bytes back. A refusal here\ncosts nothing for the same reason a gate failure does: no claim has been made and nothing has\nstarted.\n\nA restore never moves or replaces the destination's own control directory. A checkpoint excludes\n`.cotal/` by design, so a seat whose `cwd` holds one, which is the layout an operator gets by\nrunning `up` and `spawn` in a single directory, is refused before anything is staged: promoting a\ntree that cannot contain `.cotal/` over that `cwd` would carry this host's live trust material and\nmaintenance state away with the superseded tree. The refusal names the control directory it found\nand the remedy, which is to give the seat a working tree that is not a workspace root.\n\nEach seat is staged beside its own `cwd`, in `<cwd>.incoming`:\n\n1. every recorded digest is verified again over the files as they are now;\n2. the bundle is cloned into `<cwd>.incoming`, which is refused when that path already exists;\n3. the recorded base commit is verified in the clone and checked out detached, so a bundle that does\n not contain it stops the resume instead of continuing against a different history;\n4. the index diff is applied with `--index` and the worktree diff without it, both `--binary\n --allow-empty`. That order is what puts staged content back in the index rather than only in the\n worktree, and `--allow-empty` is why a seat with a clean tree is still restorable;\n5. the untracked archive is extracted.\n\nEvery seat stages before any seat is promoted. Promotion moves an existing `cwd` aside to\n`<cwd>.superseded.<timestamp>` and renames the staging directory into place, then puts the session\npointer and store files where the destination's connector reads them, then re-reads\n`git status --porcelain` in the promoted tree and compares it to the status the checkpoint recorded.\nA restore that applied without error and produced a different index is a refusal, not a warning. The\ntwo renames are the only steps that touch the path the seat will use, so a failure anywhere leaves\nevery seat's live `cwd` as it was.\n\nThe rename itself claims the superseded name, and a taken name gets a numeric suffix. The timestamp\nhas one-second resolution, so two promotions of the same seat within one second compute the same\npath; a rename onto a name that already holds a tree fails on every platform, and that failure is\nread as taken. Nothing creates the name ahead of the move, because Windows refuses to rename onto an\nexisting directory at all. A superseded tree is the thing that rename exists to keep.\n\n`git` and `tar` run as child processes with argument arrays, never a shell string.\n\nA leftover `<cwd>.incoming` refuses the resume by name. A staging directory from a failed run is the\nonly record of what failed, so nothing removes one automatically: inspect it, remove it by hand, and\nresume. A pre-existing `cwd` is renamed rather than deleted, so a wrong checkpoint costs a rename\ninstead of a tree. When a promotion fails, the renames that attempt made are undone and the staging\ntree is left where it is, as the evidence for what did not verify.\n\nA session pointer whose recorded `sessionId` is not the one the retained inventory reopens is\nrefused before anything is cloned. A session file already present at its destination is judged by\ncontent: bytes equal to the recorded digest are already restored, and different bytes under the path\nthe connector is about to read are refused with both digests rather than clobbered.\n\n`up --restore <dir>` reaches the same admission and the same restore, after the store is restored\nand validated and before commit intent is journaled. A registry-only restore resumes no seat, so it\nadmits and restores nothing.\n\nOne limit is worth stating plainly. The writer generation is claimed by exclusive create inside one\nworkspace root, so it fences two resumes on the same host and does not fence two independent\ndestinations: copy a checkpoint to two roots and both claim the same successor. A real cross-host\nfence needs a coordinate neither root owns.\n\nAuthenticated restores validate the complete\nspace trust bundle before staging, including nkeys, seed matches, JWTs, signers, and space binding;\nfull restores commit to the validated operator, system-account, data-account, and active-signer root\nchain in addition to the static/user authority fingerprint. Because the system account is part of that\ncommitment, a [`cotal up --rotate-sys`](#up) makes every full artifact taken before it unrestorable\nagainst this root: take a fresh full backup after each rotation. The composed commitment is revalidated\nimmediately before store mutation and never includes secret seeds. Restore never creates fresh auth.\nSame-path restores atomically retain the old\nsource at the journaled fallback path; alternate targets retain it in place; a missing canonical\nsource needs explicit `--accept-missing-source`. Quarantine and target restores use current canonical\nconfigs on isolated random-loopback brokers, never expose native snapshot consumers, and publish a\ncommit-intent immediately before the normal listener starts. Archive bytes never instantiate the real\ntarget: after quarantine validation, every stream is re-snapshotted from the validated quarantine\nstate into attempt-owned sanitized files, and the target is restored solely from those. Before that boundary, failure rolls back\nthe attempt-owned target; after it, ambiguity preserves both stores and records forward-repair\nrecourse. The cooperative maintenance lock excludes Cotal commands, not arbitrary raw NATS processes.\n\nBootstrap brokers in every auth mode, including open, mount the store under a local account with\nrandom operation-specific logins only, each carrying the exact per-phase subject permission matrix;\nnormal static credentials and user-auth sentinel/bearer connections are rejected, and no auth\nservice or callout starts. Open mode differs only in its account label, never in authority. Inventory, each stream snapshot,\nrestore initiation, exact upload id, validation, and each checkpoint recreation use separate exact\nauthorities. Every checkpoint carries the source stream's message/first/last sequence state and must\nmatch its snapshot record before mutation; core then derives and validates the only allowed start\npolicy. TASK is not a CLI exception: the same core checkpoint API recreates its canonical `DeliverAll`\nWorkQueue durable because acknowledged tasks are absent from retention and NATS forbids a\nstart-sequence policy there. Registry-only restore creates every omitted canonical stream and transient\nbucket on the isolated target before the normal listener is exposed. It deliberately does not resume\nretained agents or recreate their DM/DLV/TASK/ACL state; their identity material stays retained and\nstopped rather than being reprovisioned into a partial restore.\n\nAfter listener readiness, the manager starts attempt-bound, validates retained credentials/tokens\nwithout granting or reprovisioning, and resumes the exact persisted principals under cleanup\nsuppression. Registry-only restore uses the same flow with an empty agent set. On a user-auth mesh\nthese manager calls run as the logged-in operator's `cli` actor, the caller the preserve cut used, so\nthat actor needs a current `admin` grant. `commitResume` is an\nidempotent validation barrier only: success must be `awaitingFinalize` with an attempt-bound 64-hex\ncommit token and does not release suppression. Under the workspace lock, the CLI first fsyncs that\nexact evidence as `manager-committed` (restore) or `resume-committed` (ordinary resume), then calls\ntoken-bound `finalizeResume`; only an `active` response for the exact token releases suppression. The\nCLI records the same token in finalization evidence before a restore becomes `active`, or before an\nordinary resume retires and consumes the marker. Re-entry from either committed state skips the prior\nidempotent activation/commit phases, retries finalization with the durable token, and finishes the\nworkspace transition. Failure before finalization preserves the committed state and cleanup\nsuppression; it is not rewritten through a degraded transition. Re-entry between any two earlier\nboundaries reuses the same attempt and may retry the idempotent phases without deleting retained state. A missing or\nchanged per-agent dependency is a named fail-closed result; the journal becomes degraded and remains\navailable for forward repair. A retry from `resume-intent`,\n`resume-active`, or `resume-degraded` reuses the same attempt and inventory after the prior listener is\nproven stopped. A retained agent the lost manager already launched can still be running, for example\nin a tmux window, while the journal reads `resume-intent`. On a static mesh the replacement manager\ncloses that seat through the reference the lost manager recorded on the agent's slot, waits for the\nprincipal to leave presence, and launches it again. A live principal with no such record, or one that\nstays live after the seat is closed, is refused. Every normal restore listener has an unguessable\nattempt-bound NATS server name. The CLI fsyncs its exact name/nonce, canonical endpoint, process owner, and generation-bound target identity\nimmediately after spawn. Re-entry accepts a surviving listener only when its INFO server name, live PID\nrecord, endpoint, and target identity all match that proof; degraded restore repair then moves through\nthe guarded workspace transition only after manager commit. If an uncommitted bound owner is provably\ndead, recovery retires that exact proof under the maintenance lock and binds a fresh listener for the\nsame attempt, endpoint, and target with a new nonce and server name. A live foreign/mismatched listener\nor ambiguous owner is preserved and refused, never adopted by reachability alone. A reconstructed\ncommit/degraded attempt without either the exact bound proof or a durable dead-listener replacement\nrecord fails closed even when the recorded port is free. A later ordinary startup may pass an `active`\nrestore only when its details prove manager commit and its exact recorded listener is dead.\n\n## Mesh registry\n\n```bash\ncotal meshes [--json]\ncotal meshes add # guided, on a terminal\ncotal meshes add <space> --server <url> [--root <dir>] [--mode auth|open|user] [--tls] [--force]\ncotal meshes add <space> --mode user (--user-auth-file <bundle.json> | --from <https url>)\ncotal meshes rm <space> [<space> \u2026] [--force]\ncotal sync [--idp <auth base URL>]\ncotal use <space>\ncotal status [--space <s>] [--server <url>] [--components]\n```\n\n`meshes` lists the meshes this machine knows; a `*` marks the `current` default a bare\n`cotal spawn` joins. Entries learned from a signed-in account are marked `discovered`. Their\nregistration trust is stored under the account's private auth state, and the registry contains no\nsession token or sentinel credential bytes. Commands resolve the catalog `slug`; a different human\n`name` is rendered only as a label.\n\n`meshes --json` prints one JSON object per recorded mesh per line: `space`, `server`, `mode`,\n`root`, `default` (the `*`), and `origin` (`up`, `manual`, or `catalog` for a discovered entry). A\nlocal or hand-registered entry also carries `offline`. A discovered entry is never probed, so it has\nno `offline` field. `tlsRequired`, `events: \"required\"` and a discovered entry's `catalogName` appear\nonly when the record has them. An empty registry prints nothing and exits 0. The note about a default\nthat matches no record goes to stderr, so stdout carries only rows, on a first run too. The table is\npresentation and is not a stable parsing target. `meshes add` and `meshes rm` refuse `--json`.\n\nA registry record this build cannot use is refused by name, never rendered and never skipped. One\nthat does not parse, or is missing a field every consumer reads (`server`, `mode`, `root`, `ts`,\n`space`), makes every registry command exit 1 with the file's path and what is wrong with it.\nRemove the file or restore the record; nothing repairs or invents a field for you.\n\nAn IdP may advertise a same-origin space catalog during login. Cotal reads the complete snapshot and\nadds every valid registration without a separate `meshes add`. A snapshot younger than five seconds\nis used without a request. After that, commands that resolve a mesh target conditionally refresh the\nsaved catalogs. An operation targeting a discovered space refreshes only that space's account and\nrefuses if that account fails. Operations targeting local or manually registered meshes refresh every\naccount, print one warning for each failure, and continue. `cotal status` refreshes every account,\nnever refuses on a refresh failure, and lists each account as `fresh`, `updated`, `not-modified`,\n`no-catalog`, or `failed` with its error. `cotal sync` bypasses freshness and reports added, changed,\nremoved, unchanged, and name collisions. `--idp` limits it to one signed-in account. It never connects\nto a broker.\n\nThe registry is updated under the same lock that guards the catalog cache, so a command never lists\na discovered space set that another command is still writing. The cache records a fetched snapshot\nas not yet applied before the first registry write and as applied after the last. If a command dies\nor is stopped in between, the next command applies that snapshot again before it can use it, with\nno request inside the freshness window.\n\nThe shared dispatcher applies this preparation to every command that declares both `--space` and\n`--server` as mesh-target flags, including commands registered by other packages and commands that\ndeclare their own equivalent flag objects. Daemon and startup commands that use those names only as\nconfiguration explicitly opt out. Registry-local `meshes add` and `meshes rm` never refresh a catalog.\nWhile the registry holds a record this build cannot use, the preparation neither refreshes nor\napplies a catalog, so the command's own checks run first. A snapshot left unapplied is applied by the\nnext preparation after the record is restored or removed. A command that resolves its target through\nthe registry still refuses the record by name.\n\nRun on a terminal with the space or `--server` missing, **`meshes add` is guided**: it asks for the\none thing that cannot be derived (the broker URL), probes it, and tells you what answered - open or\nrequiring credentials. It then offers the spaces your `--root` already holds credentials for, states\nthe mode as a fact about that broker rather than asking, and shows the exact record before writing\nanything. A broker that does not answer, or a space name already registered, becomes a choice rather\nthan an error. Anything you pass on the command line is taken as given and not asked again. Without\na terminal - a script, an agent, CI - nothing prompts and the flag form's errors stand\n(`COTAL_NO_PROMPT=1` forces that too).\n\n`cotal up` and `cotal down` maintain their own records. `meshes add` registers a mesh they cannot\nspeak for: one running on another machine, a shared broker, a hosted space. `--root` is the folder\nwhose `.cotal/auth` holds that mesh's credentials and whose `.cotal/agents` holds its personas.\nThe default is the project you run it in. The registry stores that path, never a secret. `--mode`\ndefaults to `auth` when the root holds the space's account record and to `open` otherwise. The\nbroker is probed before anything is recorded, so a wrong address, or credentials that mesh will\nnot accept, fails here instead of at the first `spawn`; `--force` records without verifying (and\nreplaces an existing record).\n\nA hostname or public address is registrable only when the connection will **require TLS**. Pass\n`--tls`, or use a `tls://` URL. The scheme is recorded as enforced intent, so every later dial\nthrough the record demands the handshake (and `meshes add tls://\u2026` against a plaintext broker is\nrefused at registration). Without required TLS the fence admits loopback and private-overlay\nliterals only. RFC1918 addresses are refused in both modes because a cafe LAN is private but does not belong to you.\n\nA **user-auth** mesh registers from supplied pinned trust, never guessed: `--user-auth-file`\ntakes the bundle exported where the mesh runs; `--from` asks before it dials the address at all,\nthen fetches the `/.well-known/cotal-mesh` discovery document under that address (HTTPS only; a URL\nthat already ends in that path is fetched as given), displays the pins, and asks again before\nadopting them. Neither fetch follows redirects: a 302 can move a pinned fetch\nonto plaintext or onto another host, so it is refused rather than followed, and the pinned\nexchange must itself be an `https://` URL, except for an exchange on this machine, where plain\n`http://` is accepted for a loopback *literal* (`127.0.0.1`, `::1`, any spelling of them) but not\nfor `localhost`, which is a name rather than an address. Registration verifies that the exchange\nanswers `/health` and `/jwks` as the pinned issuer. It also verifies that the broker refuses a bare\nconnect; that auth-required refusal is the pass. The sentinel credentials land in a 0600 file under\nthe entry's root; the registry records only the path.\n\n`meshes rm` drops records. It never stops a mesh. For a mesh running on this machine `cotal down`\nis the right verb, and `rm` says so unless you pass `--force`. A hand-added record is removed by\n`meshes rm`, by an `add --force` replacement, or by a `cotal up` that actually starts the broker for that same space, server and root, which becomes that\nmesh and so takes the record over (a `cotal up` for that space anywhere else refuses instead).\nNothing that merely *infers* a record is stale from a dead broker touches it: an\nunreachable broker is listed `offline` and stays, whether `cotal up` or `cotal meshes add`\nwrote the record; a foreground `up` whose broker exits unexpectedly keeps its record the same way.\nA bare command does not treat that offline record as a running mesh;\nname it with `--space` to restart it. `cotal down` / `cotal clean all` still drop an `up` record for the project\nthey tear down; a hand-added one they leave alone even when it shares a root, because nothing\non this machine could write it back.\n\nA discovered entry belongs to the normalized IdP origin and proved subject that supplied it. Local\nteardown, cleanup, and liveness pruning do not remove it. A manual or locally started entry with the\nsame name wins and remains untouched; that discovered name is reported as a collision. Logging out\nremoves only the discovered entries owned by that account.\n\n`cotal meshes` and `cotal status` print `events: required` for a registration carrying\n`policy: { events: \"required\" }`. On that space, foreground spawn, detached spawn, manager starts,\nand interactive `join` cannot opt out or join without an event plane. `--no-events` is refused with\nthe space named. A connector without an event plane is refused with both the space and connector\nnamed. A session whose own grant omits `events.<owner>.<actor>` is refused before joining and the\nmessage names a full-row `actor grant` repair. A running seat whose event plane stops for good on\nthat space stops too.\n\n`use <space>` sets that default; the selection applies from every directory,\nincluding inside another mesh's project. `status` is a read-only report: machine prerequisites\n(starting with the installed `cotal-ai` version), the installed extensions and their versions, this\nfolder's `.cotal/`, the recorded meshes, and a live snapshot of the selected mesh (roster, channels,\nmembership feed). Stale Claude skills and out-of-date `.agents` skills recommend `cotal setup --skills`,\nnot unscoped `cotal setup`. `status` takes `--space` / `--server` to pick the mesh to inspect; it starts\nnothing. The manager row asks the service endpoint once: a live process that does not answer is\n`not serving`, and a probe that could not be made leaves the row `running \xB7 service unchecked`.\nA process row whose PID record exists but cannot be read, or exists under both its current and a\npre-upgrade name, reads `pidfile unreadable` with the error, and the other rows still print. A live\nmanager whose delivery-aware marker cannot be read keeps its row and names the failure as\n`delivery-aware marker unreadable` with the error. The `Web process` row\nprints the address the selected mesh's dashboard recorded in `web.session` once it was listening,\nwhile the PID in its `web.pid` is alive. A `web.pid` or `web.session` that exists but cannot be read\nis named on the row with the error. Otherwise it reads `down`, or `not installed` without the web\nextension. Status and the `cotal setup` card count the web extension as installed only when a\npackage in the extensions manifest provides `web` and that package is on disk. A web package record\nthat cannot be read is named on the `Web extension` row and on the card's web row, after the\ndashboard's address when it is listening and beside an unreadable `web.pid` or `web.session`. The\n`Web process` row still prints, and reads `down` unless the dashboard is listening.\n\nIf a refresh fails, `status` may still show the kept catalog bytes for diagnosis. It labels them\nstale with the last successful snapshot timestamp and the refresh error. It never calls that state\nsynchronized or online. If a selected discovered space vanishes from a successful snapshot, the\nselection is cleared and the command reports that no default is selected.\n\nFor a user-auth mesh the selected-mesh section reports the login `status` works as: the signed-in\nsubject when this machine holds a cached session for the entry's pinned IdP, or the exact `cotal\nlogin --idp <url>` line when it does not, with no network round trip either way. A locally\nprovisioned space also shows the actor grant row; a discovered or registered remote entry reports\nthe grant as not checkable on this machine, because the ledger runs where the space was\nprovisioned. `--components` on a user-mode target probes as that same signed-in login (`ps`'s\ncredential), never a static mint; when the login cannot supply a credential, the row says why\ninstead of printing the broker's refusal of an unauthenticated probe.\n\nPersona rows name the catalog they describe. If this folder and the selected mesh use different\ncatalogs, status names both and marks which one spawn launches from. A green `default` means the file\npasses the same agent-file loader spawn uses; a present but invalid file is reported as invalid.\n\n`cotal status --components` adds a fail-loud per-component health pass. It reads **each\ncomponent's own control surface**, rather than treating a PID, a lease, or a successful probe of a\nsibling as proof that the component serves. It prints one of `serving`, `absent`, `not-serving`, or\n`refused` for each component and exits `0`, `1`, `2`, or `3` respectively (the highest observed\nstate wins):\n\n- **manager**: local PID record, its liveness-lease holder and PID, then the manager's own typed\n `status` service reachability from this host. Manager builds that do not report static\n reconciliation say `static reconciliation not reported by this manager build`; the line stays\n visible even when the manager is otherwise `serving`.\n- **delivery**: local PID record, its ready lease (`ready` is the daemon's own bound-control\n signal), and the latest `renewal.<spaceKey>.json` adoption verdict, the record of the space the\n command was asked about, keyed per space the way the pidfiles are. A re-signed credential and a\n broker-accepted adoption stay distinct facts. A root-only `renewal.json` left by an older build\n names no space and is never read as any space's verdict (`doctor auth` names it as a leftover).\n- **web**: local PID record, then the `/api/meta` response at the address the dashboard recorded in\n `web.session` once it was listening, which must name the same PID. The probe presents the\n readiness nonce recorded beside that address, the one credential the dashboard accepts on\n `/api/meta`. A live PID with no recorded address (the dashboard is still writing it, or an\n earlier build started it), or an unrecognizable process record, is `refused`, not a green\n default-port guess. A `web.session` that exists but cannot be read is also `refused`, and the row\n names the read error.\n- **broker**: the registered mesh URL dialed from this host with its recorded TLS requirement.\n\n`absent` means Cotal has no live local component record (or has a stale record); `not-serving`\nmeans the component record is live but its service/readiness surface did not answer or is not ready.\nThose are intentionally separate exit cases. A failed or unreadable probe is `refused`, never an\nabsent component or a clean zero. A PID record that exists but cannot be read, or exists under both\nits current and a pre-upgrade name, refuses only its own row. A record that its component removes\nwhile the pass runs reads as `absent`.\n\n## spawn\n\n```bash\ncotal spawn [<persona>] [--detach] [--name <n>] [--agent <a>] [--model <m>] [--variant <v>] [--prompt <text>] [--cwd <dir>]\ncotal spawn -f <cotal.yaml> [--dry-run]\n```\n\nFor a foreground spawn onto a remote user-auth mesh, a launcher may supply a one-time enrollment\ninstead of a cached human login. Prefer a private file:\n\n```bash\nCOTAL_ENROLLMENT_FILE=/run/secrets/cotal-enrollment \\\n cotal spawn --config ./seat.md --space main\n```\n\nThe file contains only the enrollment URL, ending with at most one line terminator, and must be\nmode `0600` on POSIX. An orchestrator that cannot mount a file may set `COTAL_ENROLLMENT_URL`\ninstead; that value is redeemed byte for byte, so a trailing newline in it is refused. Setting both\nis refused. Enrollment input\nrequires `--space` and applies only to a foreground persona spawn. If the mesh is not registered yet,\nthe enrollment response must carry the stock user-bundle fields and the command needs\n`--config <persona-file>` because there is no local remote-mesh persona catalog to read. The client\nredeems the URL once, registers the returned mesh material, exchanges the returned actor token at the\npinned auth service, and removes both enrollment variables before starting any child process.\n\nA cached login for the same IdP and an enrollment are conflicting proofs, so the command refuses\nrather than choosing one. An invalid enrollment never falls back to login provisioning. Unknown,\nexpired, revoked, and already-used enrollments all produce one response: ask the owner for a fresh\none. See [Enrollment redeem](identity-and-auth.md#enrollment-redeem) for the HTTP contract.\n\nA runtime that starts a managed seat outside the manager's filesystem hands the child a managed\nhandoff instead: one `0600` file named by `COTAL_MANAGED_HANDOFF_FILE`, carrying the lifecycle the\nmanager already enrolled. The runtime builds the command with `delegatedSeatCommand`:\n\n```bash\nCOTAL_MANAGED_HANDOFF_FILE=/run/seat/handoff.json \\\n cotal spawn --config ./seat.md --space main --name <actor> --agent claude \\\n --expect-owner <owner> --expect-lifecycle-uid <uid>\n```\n\nThe `cotal` entry reads the file, deletes it and drops the variable before it parses flags, prints\nhelp or loads extensions, so every outcome leaves no file. The variable is read under any letter\ncase; spellings that name different files are refused after every one of them was deleted. The\nspawn then refuses a malformed handoff, or one whose space, owner, actor or lifecycle UID differs\nfrom `--space`, `--expect-owner`, `--name` and `--expect-lifecycle-uid`, before any broker\nconnection or exchange request. Every refusal on this path names the field and never a value from\nthe handoff. The registration's server, exchange and enforcement checks, the local state this\nmachine keeps for the space (its mesh record, user-auth state and agent secret files), target\nresolution, the policy refresh, the broker preflight and the agent auth preflight quote the space,\nthe server, the exchange URL, the actor or a path named for one of them in their own diagnostics and\nin the filesystem errors under them. For a handoff each prints one fixed sentence that names the\nfield and the phase instead, whether its check fails or an error is thrown. When the agent auth\npreflight's rollback then fails to remove a secret or file, that sentence is followed by the names of\nthe cleanup steps that failed, without their errors. The event-plane policy\nrefusals name the handoff's space field. An actor outside `[A-Za-z0-9_]` and a space that cannot\nname local state, such as `..`, are refused as malformed before any plane. A handoff conflicts with\nthe enrollment variables, `--detach`, `-f` and `--creds`, and needs `--config <persona-file>`. From\nthere it runs the enrollment consumer above without redeeming anything. See\n[Delegated seats](embedding.md#delegated-seats-outside-the-managers-filesystem).\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | resolved mesh | Target space |\n| `--server <url>` | registry entry | Broker URL override |\n| `--creds <path>` | none | Control-caller creds for an off-registry manager (`--detach` only) |\n| `--name <n>` | persona's `name:` | Presence-name override (does not choose the persona) |\n| `--config <persona-or-path>` | none | Persona catalog name or file path; wins over the positional |\n| `--agent <a>` | persona's `agent:`, else `COTAL_DEFAULT_AGENT`, else `claude` | Connector type (`claude`, `opencode`, `jcode`, `hermes`, and so on) |\n| `--role <r>` | persona's `role:` | Role override |\n| `--model <m>` | persona's `model:` | Model override |\n| `--variant <v>` | persona's `variant:` | Model variant override (connector-defined; e.g. OpenCode reasoning tiers) |\n| `--cwd <dir>` | this cwd | Working directory to root the agent at. Refused before launch when the directory does not exist on the serving manager's host. |\n| `--prompt <text>` | none | Initial prompt auto-submitted at start |\n| `--resume <id>` | none | Fork an existing session id into the mesh; only connectors that declare resume support accept it (see [the matrix](connectors.md)). The manager records the source session id, and `ps --wide` shows it. With `--detach --on <instance>`, a Claude session held on this host is carried to that instance first ([Resume a session](connect-claude.md#resume-a-session)); carrying one needs `--on` |\n| `--no-events` | event plane on where supported | Opt out of the session's structured event plane (`--events` only restates the default) |\n| `--share-tools <sel>` | none | Share named operator MCP servers with the agent |\n| `--subscribe <a,b>` | persona's | Channel read-set override |\n| `--allow-subscribe <a,b>` | = subscribe | Read-ACL override |\n| `--allow-publish <a,b>` | deny | Post-ACL override |\n| `--detach`, `-d` | off | Launch via the manager into a detached PTY (reattach with `cotal attach`) |\n| `--on <instance>` | class anycast | With `--detach` only: pin the launch to one manager instance id (the whole id, as `ps` prints it). Refused on a foreground spawn (no manager to pin), with `-f` (a manifest deploy launches through the manager class queue), and when empty |\n| `--file <cotal.yaml>`, `-f` | none | Deploy a manifest onto the running mesh |\n| `--dry-run` | off | With `-f`: print the plan, mutate nothing |\n| `--allow-stale <a,b>` | none | With `-f`: waive named stale agents (apply-only) |\n| `--runtime <name>` | manifest's | With `-f`: override the manifest's runtime |\n| `--expect-owner <u_\u2026>` | none | With `COTAL_MANAGED_HANDOFF_FILE` only, and required there: the owner the handoff must carry |\n| `--expect-lifecycle-uid <uid>` | none | With `COTAL_MANAGED_HANDOFF_FILE` only, and required there: the lifecycle UID the handoff must carry |\n\nEach session uses its connector's **event plane** by default: a stream of structured events\ndescribing what the agent did, rather than the prose it wrote, on a channel of its own. The channel is named after\nthe agent's principal, `events.<owner>.<actor>`, never after its display name, because two live\nagents are allowed to share a display name and would then share a stream. The launch grants publish\nrights on that channel alone, foreground and detached alike. On an open mesh, which issues no\ncredentials, the launch still allocates the agent an id, so the channel names a stable actor.\n`--no-events` is the explicit opt-out unless the selected registration says\n`policy: { events: \"required\" }`. Required policy makes the\nevent arm and grant mandatory, so `--no-events` and connectors without an event plane are refused.\n\nThe launch decision and the grant are separate on purpose. Holding publish rights on a channel is\nnot a request to publish to it, so writing an event channel into an agent file's `allowPublish`\ndoes not override `--no-events`.\n\nThe persona (`--config` > positional > `COTAL_DEFAULT_PERSONA` > `default`) is loaded from the\ntarget mesh's `.cotal/agents/` when it is a bare name. A reference that contains a path separator or\nends in `.md` is loaded from that file. A relative path resolves against the mesh root, except that\nan enrollment or a managed handoff resolves `--config` against the working directory. A missing\npersona is refused with the catalog directory or the file that was checked. The launch flags\noverride the file. On a user-auth mesh the\neffective name is also the agent's actor token, so it must match the token grammar (no `-`); the\nspawn is refused with that explanation before any request is sent. Foreground runs the agent\nattached to your terminal; `--detach` hands the launch to the running manager. Both modes get the\ndurable backstop on a mesh that runs the delivery daemon; `--live-only` skips it for a foreground\nspawn (messages posted while it is disconnected are then not replayed). A foreground exit retires\nthe agent's creds and broker footprint, like a manager despawn. The creds and the footprint are\nremoved as separate steps, so a creds file that cannot be deleted does not leave the broker durables\nbehind, and the failed steps are reported together. On a user-auth mesh the two arms\ndiffer: a spawn against a mesh this machine provisioned revokes the actor row on exit, while a\nremote spawn (an enrollment or the advertised provisioning endpoint) removes only this machine's\ncredential files; its grant stays until the mesh operator revokes it, and the launch line says\nwhich arm you are on. A spawn through the advertised provisioning endpoint against a record that\npins no exchange URL is refused before the grant is requested, so no credential lands on this\nmachine. A foreground `--share-tools` selection that names a server the config does not declare is\nrefused before anything is provisioned. A `--detach` spawn is an\n**action**: the manager accepts it and returns the allocated identity at once, then the launch\nfollows to a terminal outcome rather than blocking (see [the control surface](control-surface.md)).\nSee [Connect Claude Code](connect-claude.md) and [Agent files](agent-files.md); `-f` is a\n[manifest deploy](#manifest-deploys). (`cotal start` was merged into `cotal spawn --detach`.)\nA `--detach` spawn onto a manager from another Cotal release is refused before any request is sent\nwhen the manager's contract does not declare a field this CLI sends. The refusal names the field,\ncalls it version skew, and gives this CLI's version. A field you leave unset is not sent, so it\nnever causes that refusal.\n\nA manager has 50 seat slots, and each seat counts once. A slot is held by a managed seat (a row in\nthat manager's `cotal ps`, including a seat still joining), by a reserved launch the manager accepted\nbut has not started a process for, or by a cooling hold. A seat that ends within 10 seconds of\nstarting leaves its slot cooling until those 10 seconds pass, unless an operator stopped it. Such a\nseat holds only that cooling slot, even while its launch is still reporting the failure. A spawn\nrefused at the limit states that split and whether waiting can free a slot:\n\n```text\nat capacity (50 of 50 slots: 49 managed, 0 reserved, 1 cooling); waiting frees a cooling slot in 7s, or despawn one\n```\n\nA cooling slot frees at the stated time. A launch that has not settled frees its slot only if it\nfails, and a managed seat frees its slot only when it stops. The refusal counts a launch as pending\nonly while it holds a slot, so a launch whose seat already ended is not counted. The roster counts\npresence, which also includes peers no manager owns, so its total is a different number.\n\nRun from a managed seat's own shell on a static or open mesh, `cotal spawn --detach` launches as\nthat seat when it targets the seat's own space. Without `--space` it picks that target the way the\noperator path does, so a recorded mesh that is not running is skipped. The CLI reads the seat's\nlaunch identity (`COTAL_NAME`, `COTAL_ID`, `COTAL_LIFECYCLE_UID`, `COTAL_SPACE`, and on a static\nmesh the seat's own credential), so the manager records the seat as the spawner, the same as for\nthe seat's `cotal_spawn` tool. On a static mesh that credential also proves the seat's space, so a\nlaunch without `COTAL_SPACE` still runs as the seat, and a target space holding no credential for\nthe seat is refused. An open mesh acts as the seat only when `COTAL_SPACE` names its space. The\nseat can then stop the child with `cotal_despawn`, and the manager stops the child when the seat\nexits. On a static mesh a seat whose agent file lacks `capabilities: [spawn]` is refused, because\nits credential holds no spawn subject.\n`--on <instance>` keeps its pin: the seat's own credential has no instance route, so on a static\nmesh the CLI mints a one-shot `manager-caller` view for the seat, pinned to that instance and\ncarrying the spawn subject only when the seat's credential holds it. On an open mesh the call keeps\nthe TLS requirement the mesh records. `--creds`, `--server` with an unregistered `--space`, and a\nuser-auth mesh keep the operator path.\n\nForeground user-mode launches serialize their local actor material. A duplicate refuses\nwithout replacing the first launch's files. A new local-user foreground launch also refuses\nan existing grant before rotating it. Departure cleanup is conditional on the launch's token\nand lifecycle generation, so an earlier launch cannot revoke a successor's grant. Remote\nprovisioning remains the mesh operator's authority and can reissue material before the\nclient observes an existing same-host consumer.\n\n## models\n\n```bash\ncotal models [--agent <connector>] [--refresh]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which manager to reach |\n| `--agent <connector>` | all registered connectors | Connector whose catalog to list |\n| `--refresh` | off | Ask the connector to refresh its provider cache |\n\nAsks the running manager for each connector's model catalog (model ids plus their variants)\nfor connectors that expose one. OpenCode and Codex query harness/provider surfaces; Jcode reads\nproviders that enable `model_catalog = true` in the operator Jcode `config.toml`. Jcode's listed\neffort tiers render as `variants (declared, not provider-verified)`, and launch can still refuse one.\nA connector without a catalog says so. A connector whose harness the manager did not find at boot\nreports the reason boot recorded, as a spawn does, so restart the manager after installing it. Pick\na result with `cotal spawn --model <id> --variant <v>`, where `<id>` is the model id as the catalog\nprinted it. OpenCode and Codex ids are the full\n`provider/model`; Jcode ids are bare (`opus-5`, not `cliproxy/opus-5`), because the provider is\nselected by the operator's Jcode config and a prefixed id is refused at launch with the bare form\nnamed.\n\n## endpoints\n\n```bash\ncotal endpoints [--space <s>] [--server <url>] [--creds <path>]\n```\n\nLists the mesh presence roster: agents, the manager, and any other protocol endpoint, with each\nendpoint's role, kind, status, and current activity. Unlike `ps`, this is a read-only presence view;\nit is not limited to child processes owned by the manager.\n\n## Endpoint control\n\n```bash\ncotal describe <endpoint> [--on <instance>] [--space <s>]\ncotal invoke <endpoint> <command> [--args '<json>'] [--space <s>]\ncotal invoke <endpoint> <command> --name <agent> [--admin] [--space <s>]\n```\n\nThe generic v0.4 service surface. `describe` resolves a registered endpoint's command set off the\nwire - the reserved `describe` command answers the registered contract digests, the schemas are\nfetched from the space's content-addressed contract store, recompiled, and verified against those\ndigests - and prints each command with its capability class and targeting shape. `--on <instance>`\npins `describe` to one manager instance's rail (the whole id, as `ps` prints it under its\n`manager <id>` headers), so an operator can read what that instance serves in a multi-manager space;\nunpinned, the class queue answers and the attribution line names whichever instance did. `invoke`\ncalls one command by name: `--args` is a JSON object validated against the fetched input schema\n*before*\npublish; a targeted command takes `--name <agent>` (resolved to the agent's current principal through\n`inspect`) or `--self`. `--admin` uses the admin instrument credential, whose cross-agent reach rides\nthe operator-only `any` authorization mode. Neither command has compile-time knowledge of any\nendpoint's schemas - this is the same trust chain every built-in control command now uses. Needs an\nauth mesh: the manager registers its service on both static and per-user meshes (a signed-in user\nrides their bearer; each visible or invoked command still requires its existing grant, and cross-agent\nreach needs the `admin` scope). An open mesh has no service registry.\n\n## Managed seats\n\n```bash\ncotal ps [--on <instance>] [--wide | --json] [--slots] [--space <s>]\ncotal stop --name <n> [--on <instance>] [--space <s>]\ncotal attach --name <n> [--on <instance>] [--no-reconnect] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which manager to reach |\n| `--name <n>` | none | Managed agent to stop / attach (required) |\n| `--on <instance>` | class anycast (`ps`: class scatter) | Pin to one manager instance id (multi-manager space); takes the whole id as `ps` prints it, not a prefix. An empty value (`--on \"\"`, an unset shell variable) is refused, never treated as absent. A roster principal id (`local.\u2026`) is refused with a message naming the instance id `ps` prints |\n| `--wide` (`ps`) | off | After each seat's compact row, print extra operational facts the manager records: the provider the connector reported serving the model, `cwd`, `pid`, spawner, lifecycle uid, the owning manager's instance id and host, and for a `--resume` seat the session it forked (`forked from <id>`, with the source title and transcript SHA-256 once a Hermes or Jcode seat has recorded its fork; a carried Claude session prints `forked from <host>:<id>` with its title, SHA-256 and `carried <time>`, the time its bytes reached the manager). Model and requested variant stay in the identity row rather than printing twice. A fact the manager did not record (for example a runtime with no real process, or a connector that reported no provider) prints nothing, never a placeholder |\n| `--json` (`ps`) | off | Machine-readable: one JSON object per seat per line, copied unchanged from the manager row. Instance headers and errors go to stderr, so stdout contains only rows. Mutually exclusive with `--wide` |\n| `--slots` (`ps`) | off | List the durable static slot rows this manager owns instead of live seats, through the `slots` command. Mutually exclusive with `--wide`. A row that is not in the live roster still prints, with `live=false`; a retired row never prints |\n| `--no-reconnect` (`attach`) | off | End the attach when its session ends, instead of re-establishing it. For scripts that want one run and one exit code |\n\nA raw `--creds` file is refused by `ps`, `stop`, `attach` and the other control commands, because\nthat route mints no endpoint-caller triple; the project folder, or `--space` against the registry\nentry, is the route that does.\n\nThe human `ps` row is presentation text and is not a stable parsing target. Scripts use `--json`,\nwhich is the machine-readable row contract.\n\n`--slots --wide` is refused: `--slots` lists durable static slot rows, `--wide` prints live seat facts, and the two answer different questions. Across a multi-manager scatter, `--slots` prints each manager's rows under its own instance header, the same way the plain `ps` scatter does.\n\nThese are operator clients over the running manager's control plane. The default row includes the\nconnector, model pin, optional requested variant, and runtime as operational descriptors for the\nmanaged row. They do not make a shared display name a unique protocol identity; use `--json` when\nunambiguous owner+actor attribution is required. An omitted variant means no override was requested;\nCotal does not invent an effective provider default it cannot observe. `ps` also prints two state\nfacts per managed agent, because they answer different questions: the process fact from the manager's\nown runtime handle (`running` with its uptime, or `exited` with how long it ran), and the mesh fact\nfrom the roster (`idle` / `working` / `waiting` / `mesh offline`, or `not in roster` when the seat has\nno presence row at all: a seat that has not joined yet, or one that never did). When the seat's\nconnector relays a harness-reported condition, the mesh fact carries its code and how long it has\nheld, so a seat whose turn died on a provider rate limit reads `waiting (rate_limit for 40m)` rather\nthan a bare `waiting`, and `--json` carries the whole `condition` object. When the connector reports\nthe seat's last work event (presence `activeAt`), the mesh fact ends with its age, such as\n`\xB7 active 3s ago`, and `--json` carries `activeAt`. A seat whose turn stopped advancing keeps\nheartbeating, so its presence row stays fresh and this age is what shows the stall. A seat can be\n`running` and `mesh offline` at once: the process is alive and its presence has lapsed. That row says\nhow long, as in `mesh offline for <age>` with an age such as `3.5h`, counted from the seat's last\npresence heartbeat, which `--json` carries as `offlineSince` (epoch ms). The age is read only from\nthe seat's own presence record, matched on its principal and lifecycle uid, so a same-named peer or\nan older lifecycle never dates it. The manager log names each managed seat that is offline on the\nmesh while its slot is held\n(`seat offline on the mesh: <name> - last heartbeat <time>; process <state>`), including one its\nwatch first sees offline after a reconnect, and each one that comes back\n(`seat back on the mesh: <name>`), so a watchdog that only checks process liveness has a line to\nact on. The manager does not reap or re-key such a seat. The mesh fact is only a verdict while the\nmanager's own presence watch is fresh: when that watch has been silent past the liveness window, or\nhas not replayed the bucket yet, every row prints `mesh unknown` with the reason instead (`--json`\ncarries it as `meshView: stale | unpopulated`), because `offline` and `not in roster` would then\ndescribe the manager's watch rather than the seat. The manager rebinds a watch that goes\nsilent under a live connection on its own, so `mesh unknown` normally clears within a liveness window.\nOn a user-auth mesh `ps` also renders each managed agent's last credential-refresh outcome, fail-closed.\n\n**Mode split (chosen up front, never try-scatter-then-degrade):**\n\n- **Static / open mesh.** Bare `ps` is a **class scatter**: it freezes the live manager class from\n the records registry, merges every registered instance's agents grouped and attributed per\n instance, and a non-answering instance is shown as `registered, no answer within the deadline`\n (never silently omitted). A refused list or a missing answer makes the census incomplete: rows\n from other instances remain visible, but `ps` prints an incomplete-census warning on stderr and\n exits non-zero, including with `--json`. Those rows are not a complete seat count. A contract\n mismatch prints one plain comparison of the requested and served input/output digest pairs and\n advises aligning manager versions. The no-answer label means only that the instance is registered\n and did not answer. It does not say the host is down, because a dead host never deregisters itself and a\n live one can be slow; if it is gone, deregister it.\n `--on <instance>` pins the read to one exact instance id instead. A wrong pin fails loud\n rather than falling through: a well-formed id that no live manager carries is reported as\n `manager instance <id> did not answer` (nothing else is asked), and a credential without that\n instance's rail is reported as refused by the broker, not as an unresponsive manager. A manager\n that answers with a refusal is shown with its own cause; \"no manager reachable\" is said only when\n nothing answered at all. If the scatter's own registry read fails (the freeze or the reconcile),\n `ps` says the manager registry could not be read rather than pronouncing on the managers, which\n may all be up.\n\n**The verdict is scoped to the endpoint rail the request rode.** An issued caller rides the\nversioned `ep.v1` rail, a separate subject space from the legacy `ep` rail, and an endpoint serves\nboth (SPEC 13.15). A manager older than the versioned rail serves `ep` alone, so it can be running,\nregistered and answering while an issued caller's request reaches nobody. Silence on `ep.v1` is\nreported as `no manager answered on the <rail> rail` with `ep.v1` as the rail, and names both causes\nit is consistent with: no manager running, or one older than the rail. The CLI cannot tell them\napart, because the service registry records no package version, so check whether a manager is\nrunning and, if it is, its version. The same scoping applies to `cotal run`'s hosted verbs, which\ndrop the `--local` suggestion there, since `--local` drives the run from the calling process and\nnames the caller as its answerer.\n\n**`stop` and `attach` route by seat locality.** A seat can only be stopped or attached by the\nmanager actually running it, and the class queue does not know which one that is. So on a\nstatic/open mesh both verbs first ask every registered instance which one hosts the named seat, then\naddress that instance directly. This happens by default; you do not need `--on`.\n\n`--on <instance>` remains the override, for when you already know where the seat lives or the\nlookup itself is degraded. On a **user-auth mesh**, the exchange selects one authorized manager\nfor a short-lived `manager-caller` view. `--on` requests a specific instance; without it, selection\nmust be unique. Discovery and the command use that instance route. The caller gains no registry\nread or scatter permission. An absent, ambiguous or unauthorized selection refuses before sending\nthe command.\n\nA seat is reported as **not found** only when every reachable instance answered for itself. An\ninstance that stayed silent past the deadline, or that refused the read rather than answering, said\nnothing about which seats it hosts, so the seat may be running on it. That case reports that the\nlocation could not be established, names the instances that did not answer, and states outright\nthat it is not a report that the seat is gone. Read it as unknown and retry with\n`--on <instance>`; a retry loop that treats it as \"already gone\" stops looking for a seat that is\nstill running. A single manager cannot tell \"hosted elsewhere\" from \"does not exist\": it answers\n`not-found` for both, which is why the search asks all of them and why an incomplete search\nconcludes nothing.\n- **User-auth mesh.** `cotal ps` reports what **one** authorized manager knows about your agents\n (an instance-addressed read against its in-memory roster, owner-filtered). It does **not** report\n other manager instances or establish whether they are reachable. Completeness across a\n multi-manager user-auth space is not claimed.\n A manager that does not answer fails the command outright (exit non-zero), rather than printing\n an empty list that could be read as \"no agents\". Your ledger row needs the `admin` scope to\n reach `ps` at all; `spawn` alone is refused by the broker (the ep tier boundary).\n\n`attach` streams and drives an agent's terminal on the `pty` runtime; detach with the escape key\n(Ctrl-] by default; see [`COTAL_DETACH_KEY`](config.md)). The key is recognised as the legacy\ncontrol byte and as the kitty keyboard protocol and xterm modifyOtherKeys encodings of the same\npress, so a terminal with either protocol enabled detaches too. It does so over a one-use, holder-bound\nmesh session ([SPEC](../SPEC.md) \xA713.6): the manager replies with a signed session grant (never a\n`127.0.0.1` URL), the CLI redeems it once over the broker, and the browser console (`cotal console`)\ndrives the same session. `stop` and `attach` need a running manager to talk to. On a static mesh\nthey are cross-agent admin operations. On a user-auth mesh, your own agents (any agent under your\nowner) need only the `spawn` scope; another owner's agent needs `admin` on your ledger row\n([identity & auth](identity-and-auth.md)). Launch detached agents with [`spawn --detach`](#spawn).\n\n**`attach` reconnects when the link dies.** A session lives on a network link, and a laptop that\nsleeps, a VPN that drops or a wifi handover kills it. When that happens `attach` prints\n`[cotal: connection lost, reconnecting]` on stderr and starts asking the manager for a new session:\na fresh grant, a fresh per-session credential, a fresh connection, so every attempt re-runs the same\nauthorization the first attach did. On success it prints `[cotal: reconnected]`, the manager repaints\nthe seat's current screen the way it does for any attach, and you carry on in the same terminal.\nRetries wait 1s, 2s, 5s, 10s, then 30s, for as long as the seat exists. The detach key is read the\nwhole time the loop runs, the waits and the attempts alike, so a reconnect never traps you: press it\nwhile a session is being established and the attach ends there, and a session that lands behind the\npress is handed back to the manager rather than left holding a slot. Everything else you type while\nthere is no session is dropped rather than queued, so keystrokes aimed at a terminal that turned out\nto be frozen, Ctrl-C included, are not delivered to the agent by a reconnect you did not know had\nhappened. That starts before the first session, not at the first reconnect: at a terminal, `attach`\nreads and drops what you type while it is still resolving the mesh, so a key struck at a prompt that\nhas not come up yet does not reach the agent when it does.\nThe terminal is in raw mode for the whole reconnect, including when the link died before the first\nsession finished opening, so the detach key works there too instead of echoing as `^]`.\n\nA **pipe** carries script input. For example, `printf 'ls\\n' | cotal attach --name web` is\nbuffered until the session opens. Buffering continues across reconnects, so\n`tail -f log | cotal attach --name web` does not lose the part of its feed written while the link was\ndown. Only a terminal gets the reader; `--no-reconnect` keeps the old behaviour on both.\n\nIt stops on its own when reconnecting cannot help, and says why: a manager that refuses the attach\nexits non-zero with the manager's own message, and a reconnect that finds the seat no longer there\n(despawned, or its agent exited while the link was down) exits cleanly with `seat <name> is gone`.\nA local connect refusal that retrying cannot fix, such as a static-auth mesh whose seed is now\nmissing, also exits non-zero with the refusal's own sentence. A broker that is still unreachable\nkeeps the loop trying in silence.\nA refusal that could still pass, such as a manager at its session ceiling, is relayed in the\nmanager's own words while the loop keeps trying, once per refusal rather than once per attempt.\nPressing the detach key, or the agent's process exiting while you are attached, ends the attach as\nit always did. `--no-reconnect` turns all of this off and restores the single-session behaviour,\nwhich is what a script wants.\n\nEach reconnect also hands the abandoned session back to the manager, over the first link that can\ncarry the message, so an attach that flaps does not eat the manager's session slots one outage at a\ntime. If that message never gets a link, the attach says so when it ends. The live-session ceiling\ndefaults to 64 concurrent sessions (`--max-sessions`); the browser console opens one session per\npane, so a dashboard over a large mesh should size for agents \xD7 panes. Hitting the ceiling refuses\nbefore a credential is minted and names `--max-sessions`.\n\nWhich mesh `attach` resolves also decides **how it redeems the grant**. On a registered open mesh\nthere is no local seed. The CLI connects bare, the same way other control commands already do, and\nthe session rail is the caller rail that a real open-mode connection already reaches. Telling the\noperator to re-register the root is false: the registered root is already the contract. On a\nstatic-auth mesh the grant is still redeemed by minting a short-lived\nsession-scoped credential from the seed at the root the mesh resolved to, never from a `.cotal`\nfound by walking up from whichever directory you happen to be standing in. The difference is not\nhypothetical: `~/.cotal` exists on every install because the mesh registry lives there, so a command\nrun anywhere under your home directory but outside a project used to mint from your home\ndirectory's trust and present it to a broker that trusts a different chain, which surfaced as a\nbare authorization failure that named nothing. A directory that does hold another chain for the\nsame space is now reported on the way past, and not obeyed:\n\n```text\n! this directory resolves to /Users/you, whose .cotal/auth holds a DIFFERENT trust chain for space \"team\".\n attach used /Users/you/projects/app, the root this mesh resolved to. The other one is not being used, and is worth a look.\n```\n\nWhen a **static-auth** mesh holds no seed at the resolved root, `attach` refuses and names what it\nresolved, the broker and the root, instead of describing a directory it did not use and instead of\ntaking the open-mode path. An authenticated registry entry with a missing seed is still\nauthenticated. On a USER-AUTH mesh `attach` reads no seed. It sends your login and the session grant to\nthe auth service, which issues a `session-caller` bearer only if your owner and actor hold that\nsession. The connection it opens expires with the session grant.\n\nTerminal bytes stream over the mesh; the manager's own HTTP/WS face serves the console. That endpoint binds\n**loopback by default**, so nothing is exposed by accident; `cotal up --host <addr>` passes its bind\naddress down, which is what lets you reach the browser console (`cotal console`) for an agent whose manager runs on another machine.\n`attach` does not use that face: it redeems a signed mesh session grant over the broker instead (see above), so it reaches a\nremote manager regardless of the bind address. A\nbare `cotal supervise` and an embedded manager stay machine-local. Set it directly with\n`supervise --console-host <host>`.\n\nThat address is **recorded on the mesh** and carried forward, because it is a decision rather than\nsomething later commands can work out for themselves (a broker dial address is not a manager bind\naddress). Every later manager launch for the same mesh reuses it, including a same-root `cotal up` repair,\nan adopted preserved or restored listener, and a `spawn -f` manifest deploy. A manager replacement\ndoes not quietly move a reachable attach face back to loopback. Passing `--host` again overrides it,\nso you can widen or narrow exposure whenever you like; a mesh that never asked stays loopback-only\nand records nothing.\n\nBecause that face mints terminal read and write authority for every managed agent's browser session, it is credentialed in two\ntiers. A mesh caller receives a **ticket** bound to the single agent the manager just authorized,\nsingle-use and short-lived, so one authorized attach can never be re-pointed at someone else's\nagent. The **console token** is the operator's own, reaches every agent, and is printed only to the\nmanager's output. The roster, the live feed, and the PTY stream all answer `401` without one; the\nstatic console shell is served openly, since it describes no agent.\n\n## input\n\n```bash\ncotal input --name <n> --text <text> [--no-enter] [--on <instance>] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which manager to reach |\n| `--name <n>` | | Managed agent to type into (required) |\n| `--text <text>` | | The text to type, taken verbatim (required) |\n| `--no-enter` | off | Type the text and stop there, without pressing Enter |\n| `--on <instance>` | class anycast | Pin to one manager instance id using the same rules as [`attach`](#managed-seats) |\n\nTypes one line into a running agent's terminal, as if you had typed it there, and returns. This is\nthe half of [`attach`](#managed-seats) that a program wants: `attach` is a live stream that holds a\nsession open and expects a terminal on your side, so a script, a cron job or a web UI cannot use it\nto send a single line. `input` is one authorized call.\n\nWhat it is for is **harness commands**. A line beginning with `/` is not chat and not a message: it\nis something the agent's own harness handles, and the only way in is the keyboard.\n\n```bash\ncotal input --name reviewer --text \"/compact\" # ask the harness to compact its context\ncotal input --name reviewer --text \"/model opus\" # switch its model\ncotal input --name reviewer --text \"hold on that PR\" # ordinary typing works too\n```\n\n**Quoting.** `--text` takes a value, so a payload starting with `/` survives as written. A payload\nstarting with a dash needs the `=` form, because the shell-style `--text --foo` is ambiguous and is\nrefused rather than guessed:\n\n```bash\ncotal input --name reviewer --text=--verbose # dash-leading text: use --text=<value>\n```\n\nEnter is pressed by default, since a command typed but never submitted has not been delivered.\n`--no-enter` types the text and leaves it sitting at the prompt, which is how you stage a line and\nsend it later.\n\nNothing comes back but a delivery receipt (`\u2713 sent 9 bytes to reviewer`, counting the trailing\ncarriage return). Whatever the agent does next shows up where its output already goes: the mesh, its\ntranscript, or an `attach`.\n\n**This one is operator-only, and more narrowly than `stop` or `attach`.** Those two are granted to\nanything holding `spawn`, so an agent can stop and attach to seats under its own owner. `input` is\nnot: it is granted only to operator credentials, which on a user-auth mesh means your ledger row\nneeds the `admin` scope, the same scope [`ps`](#managed-seats) already needs there. The reason is\nthat a write into a terminal is control of whatever is running in it, and on a user-auth mesh the\nown-owner rule covers every seat under you, not only the ones you launched: a `spawn`-scoped agent\ncould otherwise type into a sibling it never started. Seat locality is still resolved for you.\n\nOnly the `pty` runtime can be typed into. The external terminal runtimes (`tmux`, `cmux`, `orca`,\n`herdr`) attach to a process they do not own, so they have no input stream for it and the command\nrefuses by name rather than dropping the keystroke.\n\n## personas\n\n```bash\ncotal personas list [-v] [--running]\ncotal personas show <name>\ncotal personas edit <name>\ncotal personas new <name> (--prompt <t> | --from <f>) [--role <r>] [--model <m>]\ncotal personas rm <name> --force\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which mesh's persona catalog |\n| `--role <r>` | none | `new`: the persona's role |\n| `--model <m>` | none | `new`: the persona's model |\n| `--prompt <t>` | none | `new`: the persona's prompt text |\n| `--from <f>` | none | `new`: seed the prompt from a file |\n| `--verbose`, `-v` | off | `list`: include role / model / description |\n| `--running` | off | `list`: mark personas live on the mesh |\n| `--force` | none | `rm`: required, delete without prompting |\n\nPersonas are the local agent files under the resolved mesh root's `.cotal/agents/`, the same catalog\n`cotal spawn` launches from. `--space` and `--server` therefore move every list, read, write, delete\nand completion operation to the selected mesh. An unresolved target refuses rather than falling back\nto the current directory. See [Agent files](agent-files.md) for the file format.\n\n## supervise\n\n```bash\ncotal supervise [--runtime <name>] [--space <s>] [--server <url>] [--spawn <names>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | this folder's auth space | Space to supervise |\n| `--server <url>` | hosting mesh, or matching registered mesh | Broker URL. A registered mesh supplies it when omitted; a different explicit value is refused before anything is dialed. |\n| `--runtime <name>` | `pty` | Agent runtime (`pty` built in; extension runtimes are explicit-only) |\n| `--console-port <n>` | none | Protocol-console port |\n| `--console-host <host>` | loopback | Bind host for the console endpoint. Loopback keeps it machine-local; `cotal up` passes the address it bound the broker to, which is what lets the browser console reach this manager from another machine. `cotal attach` does not use this face: it redeems a mesh session grant over the broker |\n| `--max-sessions <n>` | 64 | Live-session ceiling. Each console pane and each `cotal attach` is one session, so size for agents \xD7 panes, not agent count. A capacity refusal names this flag. `cotal up --max-sessions` records the same number on the mesh so a later `supervise` started by repair or `spawn -f` keeps it |\n| `--roster <file>` | none | Declarative roster to boot at startup. See [Roster files](define-a-team.md#roster-files) |\n| `--launch <spec>` | none | Resolved manifest launch spec (from `up -f` / `spawn -f`) |\n| `--spawn <names>` | none | Comma-separated personas to pre-spawn at startup |\n\nThe manager is the agent supervisor and control plane: it answers `spawn --detach`, `stop`, `ps`,\n`attach`, and the `cotal_*` manager tools. `cotal up --detach` starts one for you; run `supervise`\ndirectly to recover a dead manager or drive a custom runtime. Default runtime is `pty`; install an\noptional provider first (`cotal ext add @cotal-ai/orca`, `@cotal-ai/tmux`, `@cotal-ai/cmux`, or `@cotal-ai/herdr`) and\nselect it explicitly. A missing provider or app fails loudly; there is no fallback. See [Deploy](deploy.md).\nBoot inventory decides whether this process takes unpinned `spawn`/`launch` on the class rail:\nif every declared connector is unavailable, those commands stay on this instance rail only\n(`status` reports `classSpawn: false`). `describe` still answers on the class rail, so an\nunpinned spawn can bind-fence against a skip member; re-issue, or pin `--on`. A partial\ninventory keeps the class rail and names `--on` on a harness refusal, because sibling\ninventories are not readable from the serve credential. See [control surface](control-surface.md#instance-routing).\n\nOn a normal `SIGINT`/`SIGTERM`, the manager stops every seat and requires the selected runtime to\nprove the seat is gone before it releases the manager lease or service registration. A stop that\ncannot prove exit fails loud and keeps manager authority instead of reporting a clean shutdown while\nan orphan still holds broker rails. When the runtime refuses the stop itself, the shutdown fails at\nonce and its error names the refusal as `stop failed: <message>`. After an abrupt manager death, the same logical successor\nterminalizes only its own durable static slots, verify-evicts the predecessor's broker principal,\nrecords that result in the lifecycle's caller-readable audit detail, reaps the predecessor's seat\nprocess through the runtime's custody reference recorded on the slot (the pty runtime verifies the\nprocess start identity in its seat record, so a reused pid is never signalled), and only then\nretires the lifecycle and frees the alias. A runtime that custodies its seats reserves that\nreference before it launches one, and the manager records it on the slot's first durable row, so a\nmanager that dies part-way through a spawn also leaves a seat its successor can address. A\nsame-lifecycle restart or a resume records the new seat's reference on the slot the same way, and\nwhen the slot does not take it the restart or resume fails and stops any seat it started, so the\nslot never names a seat that has already exited while its replacement runs. A resumed seat keeps\nits retained credentials, so the resume frees it only once its exit is proved; a seat whose stop\ncannot be proved stays managed, and the resume's error says so, naming a stop the runtime refused\nas `stop failed: <message>`. A spawn\nthat launched its seat and then failed is rolled back by the manager that launched it, and that\nrollback reaps the seat through the same reserved reference before the lifecycle retires. Missing or unverified broker evidence keeps the slot\nterminalizing, and so does a runtime that cannot reap by reference. While the name stays held, a\nsame-name spawn or a resume re-drives that teardown with the same recorded reference, so it reaps\nthe seat again or keeps the name held.\n\nA `meshes add --mode user` entry is a **participant** registration, not hosting authority. A\nparticipant may run `supervise` only when the host advertises the remote manager authority service\nand the signed-in actor has the dedicated `supervise` ledger scope. The CLI obtains the closed,\nloopback-only `manager-service` view; `spawn` and `admin` do not substitute for that scope. The\nhost issues the manager's public-nkey JWT material through its lifecycle-bound prepare \u2192 activate\n\u2192 renew protocol, never by handing the participant a signer or static provisioner credential.\nThe host also performs instance-scoped eviction and guarded gate reconciliation. A remote manager\nrefreshes its short-lived registration executor before clean deregistration, so a long-running\nprocess removes its service row on `SIGINT` or `SIGTERM`. After an unclean stop, the same instance\nverify-evicts its superseded family and advances the process epoch. If an abandoned frozen gate\nholds the manager governance slot, a different supervise-scoped manager asks the host to reconcile\nthat holder after a complete gone verdict, then retries its registration once.\n\nA remote supervise never uses local signing trust: with host-issued authority in hand, the\nmanager mints from that authority alone and consults local records only to refuse a conflict,\nnamely the supervised space's own trust records under the cwd root. A root that hosts another\nstatic space beside the sign-in is a normal configuration and is never read as this space's\ntrust.\n\nThe broker URL in the registry entry decides the transport. A remote broker is often published\nover a `wss://` edge rather than a raw `nats://` port, and `supervise` dials whichever scheme the\nrecord holds, starting with the manager-authority registration it runs before the manager exists.\nThe record also decides whether that registration requires TLS, so a participant never downgrades\nthe credential exchange to a plaintext connection the registry did not describe.\n\nWithout that advertised host service or scope, `supervise` refuses before it starts a manager.\nRun `cotal spawn` without `--detach` to launch a foreground agent, or ask the space host to enable\nthe authority service and grant `supervise` for detached agents. If a running remote manager loses\nrenewal, it reports degraded state and refuses unsafe new starts and restarts; live agents are not\nsilently replaced. Do not run `cotal down` or `cotal up` on a participant machine to repair this\ncondition.\n\n## service\n\n```bash\ncotal service install [--mesh <name>] [--linger]\ncotal service status [--mesh <name>] [--json]\ncotal service uninstall [--mesh <name>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--mesh <name>` | this folder's mesh | The mesh whose manager the service runs; one unit per mesh |\n| `--linger` | off | install: when lingering is off, ask logind to enable it so the user manager starts at boot and the service survives logout. Never enabled silently |\n| `--json` | off | status: machine-readable output |\n\nRuns the manager as a user service so it survives logout and reboot. On Linux this installs a\nsystemd user unit (`~/.config/systemd/user/cotal-manager@<key>.service`, where `<key>` is the\ncase-safe mesh key); on macOS a launchd agent plist under `~/Library/LaunchAgents/`. Any other\nplatform, or an absent systemd/launchd user session, fails with a message naming what is missing.\n\n`install` resolves the mesh from the registry and binds the unit to that entry's root and broker\naddress, so it can be run from any directory. The mesh must be registered (`cotal up` or\n`cotal meshes add`) before installing; an unregistered name refuses before anything is written.\n\nThe unit's `ExecStart` is the bare `supervise` command. The mesh facts travel in the unit's\nenvironment (`COTAL_SPACE`, `COTAL_SERVER` pinned to the registered broker URL, whatever port it\nlistens on) rather than the command line, because command lines are readable by every user on a\nmulti-user host. On Linux that environment is a `0600` `EnvironmentFile`; on macOS it is the\nplist's `EnvironmentVariables`. The same environment gives the service a private `COTAL_HOME` and\n`XDG_CONFIG_HOME` under the unit directory, so the service manager never touches the login\nuser's `~/.cotal`. First-run connector seeding runs synchronously inside `service install`,\nagainst that private config root; the unit itself starts with `COTAL_SKIP_CONNECTOR_SEED=1`\nso a manager is never interrupted mid-seed by a restart. An install whose pre-seed cannot\ncomplete (network unreachable, registry error) refuses instead of deferring.\n\nThe same environment pins `PATH` to the `PATH` of the shell that ran `install`.\nWithout it the unit inherits the service manager's own short\n`PATH`, which usually lacks `~/.local/bin` and Homebrew, so the manager's boot inventory would\nreport a harness unavailable that your shell resolves. Install from a shell that resolves every\nharness the service should launch, and reinstall after moving one. A relative entry, including\nan empty one, is resolved against the directory you ran `install` from, because the unit starts in\nthe mesh root where the same spelling names another directory. An entry with a `..` segment is\npinned as the directory your shell reaches through it, with symlinks followed, and refuses when it\nreaches none. A `PATH` set to the empty string is one empty entry, so it pins that directory. An\nunset `PATH` refuses.\n\nEvery value the unit derives from a path (`WorkingDirectory`, the `EnvironmentFile` path, the\n`ExecStart` tokens) is escaped for systemd specifiers (`%` becomes `%%`), so a mesh root that\ncontains `%` starts over its real path instead of a path systemd rewrote by expanding it. The\nprovenance comment records the root unescaped.\n\nOn Linux a user unit starts at boot and survives logout only while the user lingers. Without\nlingering, systemd starts no user manager at boot, so an enabled unit stays inert until the next\nlogin and stops at the last logout. `install` checks lingering before it writes anything, and when\nlingering is off it fails with the root command that turns it on (`sudo loginctl enable-linger\n<user>`). With `--linger` it first asks logind to enable lingering for the current user, and fails\nwith the same command when logind refuses (unprivileged users over SSH get `Access denied`).\n`service status` prints that command while lingering is off. A Linger query that does not answer\n`yes` or `no` (logind unreachable, no `loginctl`) is never read as off: `install` refuses with\nthe query's own error and enables nothing, and `service status` shows lingering as unknown with\nthat error (`--json` gives `\"linger\": { \"error\": ... }`).\n\n`service install` also refuses while a manager is already running for the mesh (`cotal down\nmanager` first). The restart policy is `Restart=always` with `RestartSec=20s`, chosen for\nmanager units in production: a manager exits for reasons that are not failures (broker\nrestarts, host suspend), where `on-failure` with a short interval thrashes.\n\nThe unit also sets a start limit (`StartLimitIntervalSec=30min`, `StartLimitBurst=20`). A manager\nthat keeps failing to start stops after 20 attempts, about seven minutes at 20 seconds apart, and\nthe unit is left `failed` instead of restarting forever. One such failure is deliberate. After an\nunclean stop, a manager that cannot verify eviction of its predecessor's credentials exits 1 and\nleaves the issuance gate frozen, because starting without that proof could let two incarnations\nserve at once (SPEC 13.1). It first waits up to 60 seconds for the delivery daemon to answer, so a\ndaemon that is still starting does not fail the start. The log names the cause. When the delivery\ndaemon is down, it says the daemon is not reachable on the `ctl.delivery-admin` rail. When the\ndaemon answers and refuses, for example because the space is missing a `$SYS` cred, it prints the\ndaemon's own reason and repair step. Fix that cause, then run `systemctl --user reset-failed\n<unit>` and `systemctl --user start <unit>`. The macOS agent has no start limit: launchd's\n`ThrottleInterval` only spaces restarts.\n\n`service status` reports the unit state from systemd/launchd, the manager's own health read from\nits pidfile at the unit's recorded root, and the machine facts a hosting side asks for:\narchitecture, OS (the platform, never the hostname), whether `/dev/kvm` is present and\naccessible, CPU count, and total memory. `--json` returns the same fields as one object. The\nmanager row names the recorded pid, and the command it runs when another program has reused that\npid. `--json` also gives the command of a live recorded pid whenever it can be read.\n\n`service uninstall` stops and disables the unit and removes it plus the private state directory.\nIt works from any directory: the unit's own records name the mesh and root it serves, and an\nexplicit `--mesh <name>` selects it. It refuses any unit that was not written by `service\ninstall` (the files carry a provenance comment), whose recorded mesh is missing, or that was\ninstalled for a different mesh, so operator-written units are never destroyed; `service status`\napplies the same rule and never reports a mesh a unit does not record. `service status` also\nrefuses a unit that records no absolute root, because the manager's health is read at that root\nand is never guessed from the current directory. Remove such a unit with `service uninstall`\nand install it again.\n\nThis command installs only the manager. The per-space auth service and the delivery daemon are\nnot installed by it: on a shared broker an operator runs three units per space with `After=`\nedges (auth service, then manager, then delivery) and stops them in reverse. A broker-side `cotal\nup` unit is a separate unit documented in [Run a mesh](run-a-mesh.md).\n\n## reconcile-gate\n\n```bash\ncotal reconcile-gate [--space <s>] [--server <url>] [--endpoint <e>] [--instance <id>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | this folder's auth space | Space the frozen gate lives in |\n| `--server <url>` | the local mesh | Broker URL |\n| `--endpoint <e>` | `manager` | Endpoint whose gate is frozen |\n| `--instance <id>` | this folder's persisted manager instance | Instance id |\n\n**When you need this.** A manager restart killed after deregistration begins but before the new\nincarnation finishes leaves the endpoint's issuance gate *frozen*, held by a\nprocess that no longer exists. The freeze is what stops two incarnations serving at once, which is\ncorrect. The successor manager now completes that dead registration itself on boot, including on\nthe remote user-auth path. A foreign remote manager blocked by this gate also asks the host to repair\nit before one registration retry. Both use the same guard this command uses: they act only when the freeze-holder is affirmatively gone under a complete\nCONNZ sweep (`gone` and `sweepComplete=true`). If that registration's spec write already committed,\nit finishes the same freeze at the committed registration revision. If the spec did not advance, it\nabort-reopens the gate at generation+1 with processEpoch unchanged and continues the normal takeover.\nLive, unknown, unestablishable, and\nwrong-op-kind still refuse; there is no TTL.\n\nUse this command when the automatic path cannot run: the delivery daemon is down, the repair targets a\nnon-manager endpoint, or you want to lift the freeze without starting a manager. It checks that the\nholder really is gone, prints what it found, and then finishes the dead operation the same way as the\ninterrupted restart would have: revoke the old credentials, evict their holders with verification,\nand reopen the gate.\n\nThe command revokes the old credentials 16 at a time. It then verifies the holders' eviction in\nshared sweeps of up to 256 holders on the delivery daemon. Each sweep scans the broker a fixed number\nof times and kicks live connections 16 at a time, so holders that are already gone add almost\nnothing and live ones add one broker round trip per 16 connections. The daemon must serve the\n`evictPrincipals` verb; an older daemon refuses it and the gate stays frozen.\n\nEach sweep durably records the holders it verified before the next sweep starts. If a holder is not\nverified gone, the command leaves the gate frozen with those records kept. An interrupted sweep\nrecords nothing, and the sweeps before it stay recorded. A retry still repeats the freeze-holder\nliveness check, then skips only progress bound to the same registration operation, frozen-gate\nrevision, and holder set.\nThe output reports holders completed before this attempt, completed now, and still remaining. A new\nfreeze or changed holder set starts from zero. Cursor cleanup happens only after reopen; a retained\ncursor is harmless because its old gate revision cannot authorize a later freeze. The governance slot\nrelease after the reopen is cleanup in the same way: a slot it fails to release does not fail the\nrepair, because the reopened gate is already past the slot's generation.\n\n**It refuses far more often than it acts, on purpose**, and always says which check stopped it:\n\n| Refusal | What it means | What to do |\n|---|---|---|\n| `holder-alive` | The freeze-holder still has a live connection: a manager *is* running | Stop that process first. Reconciling would evict a live manager's credentials |\n| `holder-unknown` | The connection sweep could not prove the holder absent | Not safe to proceed: an unprovable holder is treated as a live one. Re-run once the broker answers completely |\n| `liveness-unestablishable` | The delivery daemon gave no verdict: it was unreachable, timed out, or refused | Act on the delivery lease line in the refusal (below). Silence is never read as death |\n| `not-frozen` / `no-gate` | The gate is open, or there is no gate at that coordinate | Nothing to repair: check `--endpoint` / `--instance` |\n| `wrong-op-kind` | Frozen under a takeover or retirement, not a registration | Out of scope for this command; it will not reinterpret another operation's intent |\n| `eviction-unverified` | The holder looked gone but eviction could not be verified | The gate is left frozen, unchanged. Investigate the broker before retrying |\n| `raced` | A newer manager moved the gate mid-repair | Re-run `cotal doctor` and look again |\n\nWhen the daemon gives no verdict, the refusal also reads the delivery lease (`lease.0`) and names\nwhat is blocking the rail:\n\n| Lease reading | What to do |\n|---|---|\n| absent | No daemon is running. Start it (`cotal up` runs it) and re-run |\n| unreadable | The daemon cannot be named, so do not assume none is running. Fix the lease read, then re-run |\n| held, not ready | That holder claimed the shard and has not bound its rails. Wait for it, or stop it so its lease lapses |\n| held, ready, no answer | The query may have gone to another daemon still subscribed to the rail, such as a stopped one whose lease lapsed. Re-run before stopping anything. If no run gets an answer, stop any other delivery daemon for the space, then stop or restart the holder |\n| changed hands | The holder took the shard after the query was sent, so it was never asked. Re-run before stopping anything |\n\nThe command reads the lease before it sends the query and again after the query fails. It names a\nholder as the blocker only when the same run of the same daemon held the lease both times, and two\nrows from a daemon too old to record its run never count as the same run. Even then a ready holder\nmay not have been asked: the rail is queue-grouped, so any daemon still subscribed to it can take\nthe query. A row whose times are not valid dates reads as unreadable.\n\nA daemon that answered and refused keeps its own reason, followed by the same lease line. The lease\nline names the holder, whether it is ready, the space account that holds the lease bucket, when that\nholder acquired the shard, and when the row was last written. A ready holder rewrites the row on\nevery renewal and keeps its acquisition time, which only a successful acquisition sets. A row\nwritten by a daemon that predates the acquisition time reports it as unknown. The lease reads never\nchange the outcome: the gate stays frozen and the command exits 2. A manager's boot self-heal uses\nthe same check and reports the same line.\n\nThere is no `--force`, and no path that discards gate state: the only way this reopens a gate is by\nproving the holder is gone and then completing the operation properly.\n\n**What reopening the gate does for the endpoint's governance slot.** A registration takes the\nendpoint-wide governance slot before it publishes its spec, and holds it until its gate reopens. An\ninstance that died between those two points leaves the slot held with no registration behind it.\nThis command does not write that slot and never has; the registration path is its only writer. What\nthe reopen does is advance the holder's gate past the generation the slot is stamped with, which is\nwhat marks the slot abandoned. The next registration for that endpoint then reclaims it as part of\nits ordinary start. So the repair here is still one command followed by starting the manager, and\nthe slot needs no separate step.\n\n## deregister-instance\n\n```bash\ncotal deregister-instance [--space <s>] [--server <url>] [--endpoint <e>] [--instance <id>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | this folder's auth space | Space the instance is registered in |\n| `--server <url>` | the local mesh | Broker URL |\n| `--endpoint <e>` | `manager` | Endpoint the instance serves |\n| `--instance <id>` | this folder's persisted manager instance | Instance id, the whole id as `cotal ps` prints it |\n\n**When you need this.** The service registry records *registration*, not liveness, and nothing in\nthe model expires a row. A manager that stops cleanly removes its own registration. One whose host\ndied without writing anything cannot, so its record goes on claiming a live instance forever: every\nclass scatter in that space freezes the dead slot in, and `cotal ps`, `stop` and `attach` each pay\ntheir whole deadline waiting for a machine that is never coming back. A laptop that was reimaged, a\ncontainer that was deleted, a box that will not be back on the network: those registrations have no\nother exit.\n\nThis command is that exit. It asks the instance first, and it removes a record only when the broker\naffirms the instance's own rail is empty: nothing subscribed there. Then it deletes the\nregistration's two records keys, each pinned to the revision it read, and prints what it removed.\n\n**Silence alone never passes.** An unanswered describe is what a dead host, a wedged process and a\nslow one all look like, and a hung process still holds its subscriptions, so the broker sees\ninterest on its rail. That instance is refused and the observation is printed. A dead process holds\nno connection and therefore no subscription, so a real corpse is still removed.\n\n**Every refusal names the failed check:**\n\n| Refusal | What it means | What to do |\n|---|---|---|\n| `instance-answered` | The instance answered a pinned describe. It is alive | Nothing to repair. If it is wedged rather than gone, stop the process first; its own clean stop removes the record |\n| `instance-not-affirmed-gone` | It did not answer, and the broker did not report its rail empty, which is what a held subscription looks like: slow or hung, not affirmed gone | Nothing was removed. Stop the process; its record goes on its own clean stop, or re-run this once it is down |\n| `liveness-unestablishable` | The probe itself failed, so nothing was learned | Fix the probe's path (credential, broker) and re-run. A probe that could not run is never read as death |\n| `not-registered` | No registration at that coordinate | Check `--instance` and `--endpoint`. This takes the whole id, never a prefix |\n| `registration-in-flight` | The instance holds the endpoint governance slot at the live issuance-gate generation, so a registration is still completing | Nothing was removed. Wait for that registration to finish, then re-run |\n| `superseded` | The record moved between the read and the delete | Something is writing to it. The registration was not removed, and a status that was removed observed the inspected registration or an earlier one. Re-observe before retrying |\n\n**A held slot with an unreadable issuance gate is not one of those refusals.** When the instance\nstill holds the endpoint governance slot, the command reads its issuance-gate generation to tell a\ncompleting registration from a leftover. If that gate is absent or carries a delete marker, the\nread fails and the command refuses the delete rather than guess. The caller receives an\n`unavailable` error, not a named refusal: the message begins `could not observe the issuance-gate\ngeneration for \"<endpoint>/<instance>\" while its governance slot is held` and ends with the reader's\nown explanation, either `no endpoint gate at <key>` or `the endpoint gate <key> carries a\n<operation> marker; a gate is never deleted (corruption, not absence, SPEC 13.12)`, where\n`<operation>` is the single token `DEL` or `PURGE`. The two cases are told apart by that text\nalone; the error class and the exit are the same.\nNothing was removed. A gate is never deleted in normal operation, so treat either text as a damaged\nissuance gate for that instance and repair it before re-running. A manager's own clean stop hits the\nsame read; it logs the failure and finishes stopping, and its registration stays in place.\n\nThere is no `--force` and no sweep: silence is not death, and a rule that removed rows on silence\nwould eventually remove a live instance that was merely slow. An operator names one instance, the\nbroker's verdict on its rail is what authorizes the removal, and the guard's job is to show them\nthey named a dead one. Removal is not a one way door either. The same instance re-registers over\nthe tombstone on its next start, under the same identity.\n\n## runtimes\n\n```bash\ncotal runtimes\n```\n\nLists every agent runtime the manager can spawn through: the built-in `pty`, the official providers\n(`orca`, `tmux`, `cmux`, `herdr`), and any custom provider installed via `cotal ext add`. Each installed\nprovider is probed so you can see what is actually reachable on this machine before selecting it:\n\n```\npty built in\norca installed \xB7 reachable @cotal-ai/orca\ntmux available \xB7 cotal ext add @cotal-ai/tmux\ncmux available \xB7 cotal ext add @cotal-ai/cmux\nherdr available \xB7 cotal ext add @cotal-ai/herdr\n```\n\n`installed \xB7 reachable` / `unreachable` is the provider's own `available()` probe; `available` means\nit is a known runtime you can add with the shown command. Selecting an unknown or uninstalled runtime\nvia `up`/`spawn --runtime <name>` fails loud and, for a known one, points at the exact `cotal ext add`\npackage. There is no silent fallback to `pty`.\n\n## seats\n\n```bash\ncotal seats [--drain]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--drain` | off | Retire every seat whose agent has exited. A seat whose agent still runs is kept |\n\nThe pty runtime used to start a detached custodian process for every Linux seat. It now spawns\nin-process, but custodians that an earlier manager started keep running, and one whose agent has\nexited stays resident while a manager still holds its connection. This command lists the custody\nrecords under `COTAL_SEAT_ROOT` (default `~/.cotal/seats`), one line per seat:\n\n| State | Meaning |\n|---|---|\n| `live-child` | The agent process still runs. The seat is never signalled, and a manager can still adopt it |\n| `childless` | The agent has exited, or the record comes from an earlier boot. `--drain` retires the seat |\n| `drained` | `--drain` proved the custodian and the agent gone and removed the record |\n| `refused` | The record cannot be read, carries no start or boot identity, this host publishes no boot identity, or the reap could not prove the processes gone. The record stays on disk |\n\nA drain signals only a custodian whose recorded start identity still matches the live process, so\na reused pid is never touched. No process outlives a reboot, so a record from an earlier boot is\nreported childless and `--drain` removes it without signalling anything. A record with no start or\nboot identity is refused with or without `--drain`, and is never reported as running or exited.\nOn a host that publishes no boot identity (`/proc/sys/kernel/random/boot_id`) every record is\nrefused the same way, because no record can be tied to this boot.\nThat refusal and an unreadable record signal nothing. A refusal from the reap itself can come after the drain already\nsent `SIGKILL` to the custodian. Its detail names the pid or process group the reap could not prove\ngone, so check those processes before you retry. The command exits non-zero when any record is\nrefused. It is Linux-only and throws on other platforms.\n\n## send\n\n```bash\ncotal send dm <agent> \"<text>\" [--space <s>] [--server <url>] [--creds <path>]\ncotal send msg <channel> \"<text>\"\ncotal send ask <role> \"<text>\"\n```\n\nA `send dm` prints one line naming three facts: `\u2192 <name> stored seq <N>; recipient <status>\nat send; delivery not confirmed <text>`. `stored seq N` is the JetStream sequence the broker\nassigned to the publish; `recipient <status> at send` is the roster status (`idle`, `working`,\nor `offline`) resolved right before the publish, which can change the instant after; the send\nnever prints `delivered`, because the sender's credential cannot read the recipient's durable\nto confirm it. Inspect what the broker actually holds for a recipient with\n[`cotal deliver pending`](#deliver).\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which mesh, and (off-registry) which credential |\n\nOne-shot messaging: connect, send a single direct message (`dm`), channel post (`msg`), or role\nask/anycast (`ask`), then exit. For a running conversation, agents use the mesh tools instead\n([MCP tools](mcp-tools.md)).\n\n`cotal send` works from an operator shell or from a seat. Its display name is `<login>@<host>` of\nthe shell that ran it, so the recipient can tell one operator's send from another's; it is taken\nfrom the operating system, never from `COTAL_NAME`. The wire principal comes from the resolved\noperator credential or user bearer, not from `COTAL_NAME`, `COTAL_ID`, `COTAL_OWNER`, or\n`COTAL_ACTOR`. On an open mesh the transient endpoint self-mints its principal.\n\nThe transient endpoint never joins the roster and binds no inbox. A recipient can still answer a\n`send dm` or `send ask` with `cotal_dm`, by the sender's name or by the id on the message it\nholds: the reply is stored under the sender's id in the space's DM history, which an operator's DM\nview such as the dashboard's Direct messages lens shows. The `cotal send` that asked has already\nexited, so the reply never reaches that shell.\n\n## channels\n\n```bash\ncotal channels list\ncotal channels set <name> [--replay | --no-replay] [--window <n>] [--desc <s>] [--instructions <s>]\ncotal channels default --replay | --no-replay\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Target mesh |\n| `--replay` / `--no-replay` | none | `set`/`default`: replay history to new joiners, or not |\n| `--window <n>` | none | `set`: replay window size |\n| `--desc <s>` | none | `set`: one-line channel description |\n| `--instructions <s>` | none | `set`: instructions shown to joiners |\n\nInspects and edits the channel registry: replay policy, description, and joiner instructions. ACL\nsemantics (who may read or post) are set at mint / provision time, not here; see\n[Channels and permissions](channels-and-permissions.md). On a user-auth mesh, `list` rides your\nown login as is; `set` and `default` edit the registry over a short-lived\nchannel-writer view, which needs ledger scope `admin` ([Identity & auth](identity-and-auth.md)).\nOn a remote user-auth mesh that view is served by the public exchange; space-history `purger`\nand the read-only admin view are not.\n\n\n## history\n\n```bash\ncotal history clear --force [--dms] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Target mesh |\n| `--dms` | off | Also clear DM history |\n| `--force` | none | Required: clear without prompting |\n\nPurges retained channel history; `--dms` extends it to direct-message history. An alias of\n[`clean history`](#clean). On a user-auth mesh the purge rides a short-lived purger view over\nyour login, which needs ledger scope `admin` ([Identity & auth](identity-and-auth.md)).\n\n## console\n\n```bash\ncotal console [--plain] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Space to watch |\n| `--plain` | off | Line stream instead of the TUI |\n\nA live protocol view for a space: a lazygit-style TUI, or a plain line stream on `--plain`. On a\nuser-auth mesh it rides the read-only admin view over your login, which needs ledger scope\n`admin`. Inside the TUI, operator control (`D` kill, `:spawn`, `:status`, `:purge`) rides the\nsame per-action instrument path as `cotal stop` and `cotal ps`, never the observer; a raw\n`--creds` file cannot drive it. `a` (or `:attach <agent>`) runs\n[`cotal attach`](#managed-seats) in place and returns to the console on detach. See\n[Watch a mesh](watch-a-mesh.md).\n\n## web\n\n```bash\ncotal web [--detach] [--host <host>] [--port <n>] [--no-open] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Space to serve |\n| `--host <host>` | `127.0.0.1` | Concrete HTTP bind and browser host; wildcard addresses are refused |\n| `--port <n>` | `7799` | HTTP port, a decimal number from 1 to 65535 |\n| `--detach` | off | Run in the background; stop with `cotal down web` or bare `cotal down` |\n| `--no-open` | off | Don't open the browser |\n\nThe browser observability dashboard: presence, channels, and a live feed. It is **not** part of\n`cotal up`: it ships inside `cotal-ai` as the `@cotal-ai/web` extension, seeded automatically on first\nrun (like the built-in connectors) so it always matches your CLI version. It self-registers `cotal web`\ninto this surface and serves\n`http://cotal.localhost:7799` by default (loopback; `*.localhost` resolves in Chrome/Firefox/Edge; for Safari\nor a system resolver such as WSL2's, the launch link is also printed at `http://127.0.0.1:7799`).\nOn a user-auth mesh the dashboard rides the read-only admin view\nover your login, and a channel purge asks for its own channel-purger view per click; both need\nledger scope `admin`. The public exchange serves `channel-purger` for a remote owner; it still\nrefuses the startup admin view, so a remote `cotal web` is not a complete channel-management\nsurface. Detached mode re-execs the current Cotal installation, writes diagnostics to\nthe mesh root's `.cotal/web.log`, and reports success only after the HTTP server answers. It requires\na recorded mesh root, but can be launched from any directory once `cotal up` has recorded the mesh.\nSee [Watch a mesh](watch-a-mesh.md).\n\n## linear\n\n```bash\ncotal ext add @cotal-ai/linear\ncotal linear account add <name> --mode <write|readonly> (--token-stdin | --token-file <path>)\ncotal linear account login <name> --mode <write|readonly>\ncotal linear account show <name>\ncotal linear inventory <account> [--json]\ncotal linear call <account> <tool> [--args '<json>'] [--inventory <digest>] [--timeout <ms>]\ncotal linear resource <account> <uri> [--inventory <digest>] [--timeout <ms>]\ncotal linear prompt <account> <name> [--args '<json>'] [--inventory <digest>] [--timeout <ms>]\ncotal linear serve <account> --endpoint <reverse-dns-name> [--space <s>] [--server <url>]\ncotal linear caller <name> --endpoint <reverse-dns-name> --out <path> [--channels <a,b>] [--expires-in <s>]\n```\n\nServes one Linear account as a registered Cotal endpoint that agents call with `cotal_describe` and\n`cotal_invoke`, and gives the operator a direct client for the same server. It only talks to `https://mcp.linear.app/mcp`\n(mode `write`) or `https://mcp.linear.app/mcp/readonly` (mode `readonly`); there is no URL or header\noption. Use a separate account per Linear workspace, and prefer a `readonly` account with a\nrestricted read key wherever writes are not needed.\n\nAn account's credential is sent as `Authorization: Bearer` and is never taken from argv. `account add`\ntakes a Linear API key: `--token-stdin` stores it under the cotal home as a 0600 file, and\n`--token-file` points at an existing file that must not be readable by other users. The file is read\nat each use, so rotating it needs no restart. `account login` runs Linear's OAuth flow instead: it\nregisters a client, prints the authorize URL, and waits on a loopback redirect. It asks for scope\n`read` in `readonly` mode and `read write` in `write` mode, keeps the tokens in a 0600 file, and\nrefreshes them before a request is sent. OAuth requests only go to `https://mcp.linear.app`.\nRedirects are refused so the credential is never forwarded.\n\n`inventory` reads the server's capabilities and every page of its tools, plus resources, resource\ntemplates and prompts when the server advertises them. Names and schemas are printed as the server\nsent them. The digest covers the whole inventory; pass it to `--inventory` so a call is refused\nbefore dispatch if the inventory changed. Capabilities this command does not represent, such as\nresource subscriptions or logging, are listed under `unsupported`.\n\n`call`, `resource` and `prompt` print the server's reply as JSON. A tool result with `isError: true`\nis a normal result (exit 0). A refusal before dispatch exits 2 with outcome `not-executed`; that\nincludes a deadline that expires while queued, discovering or connecting. A protocol error, a\ntimeout, Ctrl-C, a response over the size cap, an expired session, an HTTP 401, 403 or 429 answer,\nor a transport failure after the request was sent exits 3 and is never retried. Its outcome is\n`unknown`, for read-only tools too: read-only means a repeat is safe, not that the first call did\nnot run. Errors carry an HTTP status or an error class, never the response body.\n\n### Serving the endpoint\n\n`serve` registers the account as an endpoint named by `--endpoint` and serves it until Ctrl-C. The\nname must be a reverse-DNS name in a namespace you own, such as `com.example.linear`; it is\nauthorized for this mesh's local owner only because you configured it. `serve` reads the inventory\nfirst, so an account that cannot be reached is never registered. It then walks the same path every\nregistered endpoint does: it publishes the contract to the contract store, opens the issuance gate,\nregisters a fresh instance, writes its ready status, and mints a scoped serve credential through\nthe gate. Every step runs on short-lived executor credentials scoped to this one endpoint instance.\nThe space signer stays in the `serve` process for renewal at 75% of the credential's life and is\nnever written out, printed, or handed to the serving connection. Ctrl-C stops serving, closes the\nLinear session and removes the instance's service record, each within 10 seconds. A start that\nfails after registration removes the record before it exits and says whether that removal\ncompleted; a registration that fails part-way rolls its own record back and reports when it could not.\n\nThe endpoint serves five fixed commands: `inventory`, `call-tool`, `read-resource`, `get-prompt` and\n`complete`. Each needs the one `linear.mcp` capability. That capability is permission to call the\nendpoint; which tools a call can reach is decided by the Linear account and its mode. `inventory`\nreplies in pages that fit a broker message: the first page carries the server, its capabilities,\nits instructions and per-section counts, and every page carries entries verbatim with an opaque\ncursor bound to the inventory digest. An entry is never cut: discovery refuses an inventory whose\nfirst-page head (server, instructions, capabilities) or any entry prints over 48 KiB the way an\nagent tool prints it (indented JSON, which grows with nesting), naming the entry, so the first page\nand every entry it accepts fit one reply and the tool an agent reads it with. A cursor or `inventoryDigest` from an older inventory is refused before anything is sent to\nLinear. `call-tool`, `read-resource`, `get-prompt` and `complete` require the digest the caller read.\n\n### Callers\n\n`caller` provisions an isolated caller on a static-auth mesh: an `agent` credential with its own\nmailboxes and request rows for only the five Linear commands on that endpoint, plus the channels\nin `--channels`. It carries no spawn, run, admin or provisioner capability. The credential is\nwritten 0600 to `--out` and never printed. Start a hand-driven connector session with it, using the\nprinted `COTAL_CREDS` and `COTAL_LIFECYCLE_UID`; that session then sees the endpoint through\n`cotal_describe` and calls it through `cotal_invoke`, and the broker refuses any command its\ncredential does not name. A Claude Code or jcode session also serves a local control endpoint and\nrefuses to start without one: set `COTAL_CONTROL_SOCKET` to a socket path you choose and\n`COTAL_CONTROL_TOKEN` to a random secret. OpenCode, Codex and pi sessions start without them. Managed `cotal spawn` agents on a static-auth mesh still cannot carry\nendpoint capabilities, so a Linear caller is a hand-launched seat.\n\nA per-user-auth mesh is refused by both `serve` and `caller`: there, endpoint and caller credentials\ncome from the remote auth service, which this package does not support yet. Nothing falls back to a\nlocal signer. An open mesh is refused too, because it has no credentials to scope.\n\nNot represented yet, and listed under `unsupported` when the server advertises them: resource\nsubscriptions, logging, progress notifications and experimental capabilities. The server cannot\ncall back for sampling, elicitation or roots. Every call acts as the account's one Linear identity.\n\n## deliver\n\n```bash\ncotal deliver [--space <s>] [--server <url>] [--tls] [--creds <file>] [--root <dir>] [--shard <n>] [--shards <n>] [--dev-mint]\ncotal deliver pending <name> [--limit <n>] [--durable <name>] [--json]\n```\n\nWith no positional, `cotal deliver` runs the delivery daemon (see\n[the delivery daemon](delivery-daemon.md)). `deliver pending <name>` never starts the daemon: it\nis an operator-only read over one recipient's DM durable, for the moment after a send when the\nquestion is \"what does the broker actually hold for them.\" It resolves `<name>` against a short\npresence watch (an `offline` card still counts, since the recipient may be dead, that is what\nthe verb exists to inspect); when neither a card nor the durable can be found, it prints\n`\u2717 not-found: no agent \"<name>\" and no DM durable for it in space <s>` and exits non-zero, never\n`pending 0`. On a match it prints the durable name and one fact per line: `pending`,\n`ack-pending`, `delivered`, `ack-floor`, `created`, `frontier`, and the stream's `max_age` /\n`max_msgs_per_subject` / `discard` limits (`--json` prints the same facts as one object), followed\nby a bounded, unacked read of up to `--limit` (default 20) recent candidate message ids under the\nheading `recent candidate ids (from the ack floor; not proof of a hole)`, a list of what is\nthere, not proof that nothing was lost.\n\nThe verb needs the `admin` credential profile: it runs through the same static-mesh route as\n`cotal mint --profile admin`, and refuses a user-mode mesh, naming the retired static credential,\nbecause there is no user-mode inspection authority yet. Pass `--creds <file>` for an off-registry\nadmin credential. A same-name respawn never inherits a predecessor's held DMs (the durable is\nlifecycle-keyed); an old lifecycle's durable is reachable only by the name a live read printed\n(the `<durable>` line on the first line of this verb's output). Pass that name with `--durable\n<name>` to read it directly once the lifecycle's card is gone from the roster. This skips the\npresence watch on `<name>` entirely, so `<name>` is required but only echoed in error text.\n\n## mint\n\n```bash\ncotal mint <name> [--profile <agent|observer|admin>] [--out <path>] [--signer]\ncotal mint <name> --provision [--role <role>] [--space <s>] [--server <url>]\ncotal mint <name> --expires-in <seconds> | --expires-at <unix-seconds>\ncotal mint <name> --identity <creds> [--expires-in <seconds>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--profile <agent\\|observer\\|admin>` | `agent` | Credential profile |\n| `--out <path>` | `.cotal/auth/creds/space.<key>/<name>.creds` | Output path - the default sits under the resolved space's segment (`<key>` is that space's hex encoding, as in [Project files](config.md#project-files)) |\n| `--signer` | off | Emit a stripped account-signing file instead |\n| `--force` | off | With `--signer`: overwrite an existing file |\n| `--allow-subscribe <a,b>` | the agent file's, else subscribe | Read-ACL override, **agent profile only**: `observer` and `admin` carry a fixed read set, and `mint` refuses this flag there rather than narrowing nothing |\n| `--allow-publish <a,b>` | the agent file's, else deny | Post-ACL override, **agent profile only** |\n| `--role <role>` | the agent file's | Agent profile: the anycast task queue the identity pulls (`svc_<role>`) |\n| `--provision` | off | Agent profile: also pre-create the identity's bind-only DM/deliver durables (and its role's task queue) on the live mesh, so the credential can consume |\n| `--expires-in <seconds>` | unbounded | Bound the credential's lifetime: the JWT `exp` is `iat + <seconds>`. A positive integer; refused together with `--expires-at` |\n| `--expires-at <unix-seconds>` | unbounded | Bound the credential to an absolute `exp` (unix seconds). Refused together with `--expires-in` |\n| `--identity <creds>` | a fresh identity | Re-mint for the nkey carried by this creds file, keeping the principal and every durable keyed to it. The file is read by the same loader the endpoint uses; a file with no seed is refused by name |\n| `--space <s>`, `--server <url>` | the resolved mesh | Which root supplies the agent file, static trust and default credential storage; with `--provision`, also which live mesh receives the durables |\n\nMints a NATS creds file for a space in **static** auth mode, scoped to a profile and (optionally)\nexplicit read/post ACLs. `--signer` emits an account-signing file for delegating minting to another\nhost. On POSIX the file `mint` writes is mode `0600`, including when it replaces an existing file at\n`--out`. A per-user-auth space refuses `mint`: agents there join under a logged-in user\n([`login`](#login) + [`actor grant`](#actor)), never via a handed-out creds file. See\n[Identity and auth](identity-and-auth.md).\n\nFor an agent profile, the resolved mesh root supplies the persona ACL, the signing material and the\ndefault credential destination as one authority. If the current folder also holds trust for a\ndifferent space or account, mint refuses before writing and names both roots. It never combines a\npersona from one root with credentials signed or stored under another.\n\nA plain mint is creds only: the identity can publish within its post ACL at once, but on an authed\nmesh its DM inbox and task queue are provisioner-pre-created and bind-only, so a **consuming**\nconnect fails until they exist. `--provision` performs that pre-create in the same command (a\nprovisioner cred is minted from the space's trust material, used, and dropped), so a long-running\nclient you start yourself can receive DMs and role anycasts like a spawned seat. The command prints\nthe identity's principal (its wire id) and lifecycle uid; a consuming client passes that uid as its\n`lifecycleUid`. Agent profile only; an open mesh needs none of this (peers self-create there). The\nsame resolved authority is used for both the credential and `--provision`, so the broker\nfootprint cannot be created under a different root's trust material.\n\nThe CLI-mintable profiles carry no default TTL: without a lifetime flag the credential is\nunbounded, and a standing-renewal consumer refuses it. `--expires-in <seconds>` (or\n`--expires-at`) is the door the renewal seam's own error names. `--identity <creds>` re-mints for\nthe nkey the file already carries, so the new credential presents the SAME principal and every\ndurable keyed to it survives; combine it with a lifetime flag to rotate an expiring credential\nwithout churning the identity.\n\n## Login\n\n```bash\ncotal login --idp <auth base URL> [--client-id <id>]\ncotal logout --idp <auth base URL>\n```\n\nSigns you in to a per-user-auth mesh's IdP (device code flow) and caches the session; run it\nonce per machine. The IdP URL must use `https://`. Plain `http://` is accepted only on a loopback IP\nliteral such as `127.0.0.1` or `::1`, for an IdP on this machine. `localhost` is refused because it\nis a name: a hosts entry would choose the IdP. It prints your IdP subject, the id the operator\ngrants against. When the trusted\n`/token` response advertises a same-origin space catalog, login validates and records that account's\nspaces immediately. After a\nlogin, every command on that mesh works under your identity: each connect takes a fresh IdP\nproof, exchanges it locally for a short-lived bearer, and is authorized against the actor\nledger at connect time. `logout` revokes the IdP session, clears its cache, and removes only that\naccount's discovered registry entries. See\n[identity & auth](identity-and-auth.md).\n\n## actor\n\n```bash\n# an upsert of the WHOLE row: name all three ACL flags, or pass --full for the wide defaults below\ncotal actor grant <actor> --sub <IdP subject> --scope a,b --allow-subscribe a,b --allow-publish a,b [--role <r>] [--label <l>]\ncotal actor grant <actor> --sub <IdP subject> --full [--scope a,b] [--allow-subscribe a,b] [--allow-publish a,b] [--role <r>] [--label <l>]\ncotal actor revoke <actor> (--sub <IdP subject> | --owner <u_\u2026>)\ncotal actor list\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | the folder's | Space whose ledger to manage |\n| `--sub <subject>` | none | The IdP subject (shown by `cotal login`) the actor belongs to |\n| `--owner <u_\u2026>` | none | The derived owner token (alternative to `--sub`) |\n| `--full` | off | Fill each ACL flag left off with its wide default; without it, `grant` refuses unless all three are named |\n| `--scope <a,b>` | `spawn,role:default` with `--full` | Capability scope (`''` = none; `spawn` = may run agents; `role:<r>` = may delegate role r; `admin` = cross-agent control; `supervise` = eligible for the closed remote manager-service view when the host enables it) |\n| `--allow-subscribe <a,b>` | `>` (all channels) with `--full` | Channel read ACL; the user's envelope, their agents can never read beyond it |\n| `--allow-publish <a,b>` | `>` (all channels) with `--full` | Channel post ACL; also the envelope for their agents' posting |\n| `--role <r>` | none | Role (scopes the task-queue consumer) |\n| `--label <l>` | none | Display label for `actor list` (never the IdP subject) |\n\nThe actor ledger is the single authorization source of a user-auth space: no row, no access.\n`grant --full` is the **full** envelope (all channels; scope `spawn,role:default`, so it may spawn and may delegate the default role). A\n`grant` that leaves off `--scope`, `--allow-subscribe` or `--allow-publish` without `--full` is\nrefused and writes nothing. A re-grant **replaces the whole row**, not the one field you name, so to add a capability spell\nevery field out: the new scope plus the row's current read set, post set, role and label\n(`cotal actor list` shows what a row holds). Under `--full`, a field left off does not stay as it\nwas: it reverts to the wide default in the table above. A re-grant retires the current interactive lifecycle through the running auth\nservice before it rotates the row, so copied bearers cannot cross an authorization update. If that\nretirement cannot be confirmed, the row is left unchanged and the command fails with the recovery\naction. `revoke` uses the same retirement before deleting the row, which lets a later grant create a\nreal successor instead of colliding with a live predecessor. `supervise` is separate from `spawn` and `admin`: it only makes a signed-in\nperson eligible for the host-provided closed remote manager-service view; it does not grant\nmanagement of another owner or a general host profile. `revoke` denies the next exchange and\nthe next connect with no restart, and evicts the principal's live connections. Managed-agent rows\n(written by the spawn path) live in a disjoint row space this command never touches. See\n[identity & auth](identity-and-auth.md).\n\n## doctor\n\n```bash\ncotal doctor auth [--fix]\n```\n\nCredential-health diagnosis and repair for this folder's mesh: renders every managed\ncredential as healthy / near-expiry / expired and ends in `healthy` or the exact next\ncommand; `--fix` applies the repairs it can. The one surface every stale-credential error\npoints at. `--fix` takes the mesh's renewal lease when the broker answers and refuses while\na manager or another doctor holds it; with no broker it repairs offline and says so.\n\n## join\n\n```bash\ncotal join --space <s> --name <n> [--role <r>] [--channel <c>]\ncotal join --link <url> | --token <t>\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which mesh, and which credential |\n| `--name <n>` | none | Your presence name |\n| `--role <r>` | none | Your role |\n| `--channel <c>` | none | Channel to join |\n| `--kind <k>` | `agent` | Endpoint kind |\n| `--link <url>` | none | Join link (`cotal://\u2026`) |\n| `--token <t>` | none | Join token |\n| `--lifecycle-uid <uid>` | none | Required with `--creds`: the lifecycle UID minted alongside the credential (`COTAL_LIFECYCLE_UID` works too). A credential's durable grants name exact lifecycle-keyed resources, so `join` refuses to invent one |\n| `--tls` | off | Connect over TLS |\n\nAn interactive presence: join a space under your own name and role, without launching an agent\nharness. A `--link` or `--token` supplies the where and the auth in one value. See\n[Spaces](spaces.md) and [Identity and auth](identity-and-auth.md).\n\n## Manifest deploys\n\nA `cotal.yaml` manifest declares a whole mesh (channels, personas, roles, and ACLs) in one file.\nThree commands consume it, plus a read-only validator:\n\n```bash\ncotal up -f cotal.yaml # boot a fresh mesh from the manifest\ncotal spawn -f cotal.yaml # deploy the manifest additively onto a running mesh\ncotal down -f cotal.yaml # tear that deploy down (or --run <id> for one run)\ncotal topology view -f cotal.yaml # validate + view the access graph, change nothing\n```\n\n`up -f` and `spawn -f` differ in target: `up -f` brings up a new broker and applies the manifest;\n`spawn -f` requires an already-reachable mesh and applies additively (ownership-scoped). On a\nuser-auth mesh, `spawn -f` deploys over your own login (the deployer view, gated on ledger scope\n`spawn`): the manifest's agents land under your owner, a manifest claiming another owner is\nrefused, and seeding new channels additionally needs scope `admin`. Both take\n`--dry-run` to print the plan without mutating anything. `topology` validates the manifest and\nrenders its channel / role / ACL graph. See [Define a team](define-a-team.md) and the\n[manifest reference](manifest.md).\n\n## ext\n\n```bash\ncotal ext # same as `list`\ncotal ext add <npm-package>\ncotal ext remove <name>\ncotal ext list [--json]\ncotal ext root # print just the install prefix (scriptable)\ncotal ext seed [--repair|--reset|--force]\n```\n\nOperator-installed extensions: `add` installs an npm package into a cotal-owned prefix and records\nevery registry provider it contributes. Commands appear in help, completion, and dispatch; runtime\nproviders are lazy-loaded by commands such as `supervise`; local process providers participate in\n`status` and selective `down`. `remove` and `list` manage them. The `@cotal-ai/web` dashboard is the\ncanonical command/process example. Installed packages and their location are described in\n[config](config.md). When a package needs an export its linked `@cotal-ai/*` peer does not have,\n`add` rolls back and names which install is behind, as a later load of an installed one does.\n\nBare `cotal ext` lists the inventory, headed by the install prefix. That prefix is a cotal-owned npm\nroot kept **separate** from npm's own global tree. These packages never show up in `npm list -g`,\n`cotal ext` (or the Extensions section of `cotal status`) is the canonical inventory. `cotal ext root`\nprints only the path, for scripts. The versions shown are the manifest pin recorded at add time.\n\n`ext list --json` (or bare `ext --json`) prints one JSON object per installed extension per line:\n`pkg`, `version`, `spec` (what `ext add` was given), `seeded` (`true` for an entry the built-in seed\ninstalled) and `provides` (the `kind:name` refs the table shows). No header or footer reaches stdout,\nand an empty prefix prints nothing and exits 0. The table is presentation and is not a stable parsing\ntarget. The other `ext` subcommands refuse `--json`.\n\nRemoving an extension that owns a running local process is refused with the mesh root and its\n`cotal down <component>` command; stop it first so uninstalling the package never strands a process\nwhose lifecycle provider is gone.\n\n### Built-in connectors are seeded extensions\n\nThe first-party agent connectors (`claude`, `opencode`, `codex`, `hermes`, `jcode`, `pi`) are not compiled into\nthe binary. They are seeded on first run through the **same** `ext add` path a third party uses, and\nappear in `cotal ext list` like any other extension. So you can remove one you do not want\n(`cotal ext remove @cotal-ai/connector-hermes`), and a deliberately-removed connector STAYS removed\nacross upgrades. `cotal ext add <your-package>` adds a third-party connector the same way. The web\ndashboard (`@cotal-ai/web`, providing `command:web`) is the seventh built-in seeded on the same path.\n\n`cotal ext seed` is the maintenance entry for that seeding (it runs automatically on the first real\ncommand of each boot, so you rarely call it). Each seeded connector's `\u2713 added` line goes to stderr,\nso the command that triggered the seed keeps stdout to itself:\n\n| Flag | Meaning |\n|---|---|\n| (none) | Reconcile: seed any never-seeded built-in, refresh a seeded one whose version the binary bumped, leave a removed one removed. A no-op once current. |\n| `--repair` | Recover after an interrupted seed or a lost authority (rebuilds the interrupted connector; restores the removed-vs-never-seeded record from its durable backup). |\n| `--reset` | Discard the record and re-seed all seven built-ins (the six connectors plus the web dashboard). **Resurrects any you removed.** Rebuilds cleanly over corrupt seed state. |\n| `--force` | Re-seed the built-ins even when the version stamp is current or a downgrade. |\n\nWhen a newer `cotal` advances the operator-global seed store to its generation, it prints one\nmigration line naming the old and new generations, the exact CLI entry that wrote the store, the\ncommit timestamp, and `seed/stamp.json`. That writer and timestamp are kept in the stamp, so a later\nolder CLI refusal can say which executable wrote the generation it will not overwrite and when.\nLegacy generation-only stamps remain readable; their refusal simply has no writer provenance to add.\n\nAn older `cotal` refuses a seed store written by a newer version. When it can verify a sufficient\n`cotal` executable on PATH or at the installer's `~/.local/bin/cotal` location, the refusal names\nthat absolute path so a reduced service PATH does not select the older binary again. Otherwise it\nkeeps the generic newer-version instruction. `--force` rebuilds the store for the running older\nversion without discarding the ever-seeded authority. `--reset` still exists for corrupt state and\nresurrects deliberately-removed connectors.\n\nA source-checkout CLI (`pnpm cotal`, `tsx bin/cotal.ts`, `node bin/cotal.ts`, or a suite child of\nthose) refuses to write or garbage-collect that store. The refusal names the path, the generation\nit declined, and `COTAL_SKIP_CONNECTOR_SEED=1` as the way to run other commands from a checkout,\nbecause pointing `$XDG_CONFIG_HOME` at a scratch dir alone does not lift it. With the skip set, a fresh\nconfig gets no built-in connectors (`cotal ext list` shows none), so add one from the checkout with\n`cotal ext add <checkout>/extensions/connector-claude-code` against that scratch `$XDG_CONFIG_HOME`. `COTAL_HOME` does not relocate this\nstore. An entry that cannot be proven as a released install is refused the same way. Isolated\nrelease tests that must seed from a checkout-shaped `bin/` set `COTAL_ALLOW_CHECKOUT_SEED=1` after\npointing `$XDG_CONFIG_HOME` at a scratch dir; that override is documented here, not on the refusal\nline. An opt-in write still records the checkout path in `seed/stamp.json` as `writtenBy`.\n\nThe default connector for a bare `cotal spawn` (no `--agent`) is the persona's `agent:` pin if it\nhas one, else `claude`; set `COTAL_DEFAULT_AGENT` (e.g. `opencode`) to change the fallback. It is\na default, so a persona that pins its harness still wins over it. An `--agent` naming a removed\nconnector fails loud with the exact\n`cotal ext add` to restore it. Set `COTAL_SKIP_CONNECTOR_SEED=1` to turn off the automatic first-run\nseed/refresh entirely (for a controlled or offline setup that manages connectors by hand); `cotal ext\nseed` still runs on request. `cotal agent-bearer` never takes the seed at all: it is exec'd by\nspawned seats on every bearer refresh, so it neither reconciles nor is refused by the store's\ngeneration (see [Plumbing](#plumbing)).\n\n## completion\n\n```bash\ncotal completion <bash|zsh|fish|powershell> # print a stub to eval / source\ncotal completion install [shell] # install it persistently\n```\n\nPrints or installs shell completion. Completion candidates come from each command's declared flags\nand, where useful, live mesh state (spaces, personas, managed agents) resolved offline.\n\n## feedback\n\n```bash\ncotal feedback \"<summary>\" [--type <t>] [--email <e>] [--details <text>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--type <t>` | none | `bug` \\| `idea` \\| `friction` \\| `praise` \\| `other` |\n| `--details <text>` | none | Longer free-form details |\n| `--severity <s>` | none | `low` \\| `medium` \\| `high` |\n| `--area <a>` | none | The part of Cotal this concerns |\n| `--email <e>` | git email | Contact email (required on the keyless public path) |\n| `--name <n>` | none | Your name (optional) |\n| `--url <url>` | keyed / public intake | Intake URL override |\n| `--key <k>` | `COTAL_FEEDBACK_KEY` | Feedback key |\n\nSends feedback to the Cotal developers. With a key (`--key` / `COTAL_FEEDBACK_KEY`) it routes to the\nkeyed beta intake; without one it goes to the public `cotal.ai` intake and requires a contact email\n(`--email` / `COTAL_FEEDBACK_EMAIL`, else your git email). Run a self-hosted intake with\n[`feedback-intake`](#server-daemons).\n\n## run\n\nOperate durable workflow runs (cotal-lang programs) from the terminal.\n\n```bash\ncotal run start --file <program> [--timeout <dur>] [--local]\ncotal run resume <runId> [--local --file <program>]\ncotal run ps [--endpoint <ep>] [--json]\ncotal run journal <runId> [--endpoint <ep>] [--json]\ncotal run answer <runId> <stepKey> [--value <json>] [--artifact <ref>] [--endpoint <ep>] [--local --by <who>]\ncotal run amend <runId> <stepKey> [--value <json>] [--artifact <ref>] [--endpoint <ep>] [--local --by <who>]\ncotal run migrate <runId> --local --file <program> [--endpoint <ep>]\n```\n\n`start` hands the program to the mesh's manager, which validates it, mints the run id (the record\nnever takes a caller-supplied one), drives it in its own process, and answers with the id once the\nrun is recorded; a program that does not validate is refused with every problem listed. `resume`\nasks the manager to take an existing run back and continue it from its step journal; the source is\nthe recorded program, so no `--file` is taken. Neither takes `--endpoint`: the manager records\nits runs under its own endpoint, and naming another is refused. `ps` lists the run records and\n`journal` renders one run's durable records; both only inspect. An open pause prints its question.\nA pause settled with an accepted answer prints its value as JSON plus the recorded answerer,\nartifact when present, time, and answer id, then one `amended` line per later amendment, in the\norder the store committed them, so the last is the current position.\nExpired pauses and ordinary steps print no answer line.\n`--json` on `ps` or `journal` prints each row the manager answers with (or `--local` reads) as one\nJSON object per line. A `ps` row carries `runId`, `endpoint`, `state`, `holder`, `epoch`,\n`journalHigh`, `forkedFrom`, `startedAt` and `programHash` (the values the program's `run()`\nreports; `programHash` is absent for a run with no recorded program), and `revoked` or\n`revocationUnreadable` when the marker says so. A `journal` row is an `activation` or a `step`. A\nstep row carries its `step` key, the `effect` kind and its `name`, `state`, `outcome`, the recorded\n`status` and `errorCode` once settled, and `startedAt` and `endedAt` in epoch milliseconds. An open\npause adds its `asks`, its `deadlineAt`, and for a checkpoint the `onExpiry` it was armed with; a\nsettled pause adds its `answer` and its `amendments`, as the text view prints them. A field the\njournal does not record is absent: a checkpoint opened before `onExpiry` was recorded carries none.\nThe run header and errors go to stderr, so stdout carries only rows; an unreadable revocation marker\nprints its reason there and still exits 1. The text view is presentation and is not a stable\nparsing target. `--json` on any other verb is refused.\n`answer` resolves an open\ncheckpoint through the manager, presenting as the holder that armed it; the manager records the\nanswerer from your credential, so no `--by` is taken there. A settled step refuses a second\n`answer`. `amend` records a changed position on a settled checkpoint or `ask`: it files a new\nanswer beside the accepted one, naming it, and the journal lists it under the step. The pause stays\nsettled and the run keeps the answer it acted on. A step that is still open or settled without an\nanswer refuses an amend. A spawned seat may amend only an answer recorded under its own name. `migrate` runs the migrate check of an\nedited program against a run's journal, from this terminal under a read credential (`--local`\nonly; the manager serves no run-migrate command): it prints whether the migration is admissible,\nevery orphaned step with its verdict and code, and exits 0 on admissible and non-zero on not. It\nwrites nothing: the commit that would file the migration is not reachable yet, and the report\nsays so. `--timeout` sets the default\ncheckpoint timeout for a drive (default 1h). `--local` drives in this process instead, over one\nconnection per invocation under the run's own credential minted from the project folder's trust\nmaterial, and is the path on a bare broker with no manager or for a run with no recorded program\n(`cotal run resume <runId> --local --file <program>`); `answer --local` and `amend --local` take\n`--by <who>`. On a\nuser-auth mesh the host's own manager refuses the family by name, and `--local` has no credential\nthere. A participant's manager started with `cotal supervise` hosts a logged-in user's runs through\nits issuing host: the auth callout issues the user's manager connection, and every `run` verb rides\nthe versioned rail under that issuance.\n[User-auth run start](design/user-auth-run-start.md)\nrecords the path. The guide is [workflows](workflows.md).\n\n## Server daemons\n\nTwo long-lived infra roles ship with the CLI. They are not part of everyday operation; the delivery\ndaemon comes up automatically with `cotal up --detach` in auth mode.\n\n```bash\ncotal deliver --space <s> [--server <url>] [--creds <file>] [--root <dir>]\ncotal auth-service --space <s> --server <url> [--port <n>] [--exchange-public-port <n>] [--exchange-public-url <https://\u2026>] [--exchange-trusted-proxy]\ncotal feedback-intake --keys <keys.json> [--port <n>] [--creds <file>]\n```\n\n`auth-service` runs a user-auth space's identity plane: the NATS auth callout, the\ncapability-gated local exchange and JWKS, and, when `--exchange-public-port` is set, the closed public\nexchange/discovery face forwarded by an HTTPS reverse proxy. `--exchange-public-url` is the proxy URL\nadvertised to clients; `--exchange-trusted-proxy` opts into last-hop `X-Forwarded-For` attribution.\n`cotal up --user-auth` starts and supervises the service for you, so you run it directly only to\nrecover one by hand.\n\n`deliver` runs the server-side Plane-3 delivery daemon: the durable backstop and membership/ACL\nauthority. It is auth-mode-only and single-instance (`--shard`/`--shards` accept only `N=1`);\n`--dev-mint` mints a scoped cred from the local signer for standalone dev. `--creds` can start a\ndaemon that already looks healthy, but production renewal is not that file alone: the manager and\nthe daemon must address one credential store. On a stock split host with two project roots, a\ndirect `deliver` is not an independent repair; keep the daemon under `cotal up` on the broker\nhost, or inject the same store into both processes ([embedding](embedding.md#supervisor-signing-authority)).\nTyped by hand on the workstation, `deliver` dials the broker recorded for `--space` in the mesh\nregistry (a mismatching `--server` is refused before any dial, and a record for a different\nworkspace root is refused outright); with no record for the space it falls back to the local mesh.\nThe daemon serves the workspace root that `--root <dir>` names, which must hold `.cotal/`, or else\nthe nearest `.cotal/` above its working directory. With neither, it refuses at start and names the\ndirectory it searched from, before it reads a credential or dials a broker.\nSee the [delivery daemon](delivery-daemon.md). `feedback-intake` runs a self-hosted feedback server\n(requires `--keys` and a scoped `--creds`), announcing submissions into a space channel; flags\ninclude `--host`/`--port`, `--store`, `--space`/`--channel`, `--max-bytes`, and `--rate-limit`.\n`--port` takes a decimal port from 1 to 65535 and is checked before the intake dials the broker.\n\n## Plumbing\n\n`cotal __complete <words\u2026>` is the internal entry the shell-completion stubs call to emit candidates\nfor the current command line; you never run it directly. `cotal agent-bearer` is machine-facing\nplumbing on user-auth meshes: spawned agents exec it to print a fresh short-lived bearer from their\nspawn-time secret; you never run it directly either. Its local arm uses `--dir` to discover the\ncapability-gated loopback service. A remotely enrolled, already-granted agent instead receives\n`--exchange-url <https://base>` in its launch argv: that arm sends `{owner, actor, actorToken}` to the\npinned public exchange with no local capability, follows no redirects, and refuses every non-HTTPS\nURL because the actor token is the credential in the request body. Because a seat execs it on every\nbearer refresh, it skips the connector-seed boot gate entirely: it reads one 0600 token file,\nexchanges it and prints the bearer without consulting or writing the operator-global seed store, so\na newer store generation cannot refuse a live seat's refresh. `--manager-call` asks for the\ninstance-bound `manager-caller` view; `--manager-instance <id>` selects an explicit live candidate.\nThat mode still prints only the raw token and does not update `--health-file`. A spawn runs it once as the agent auth\npreflight. When it fails there without printing a sentence of its own, the refusal names the cause: the 30 second\ntimeout, the signal that killed it, or its exit code. (`cotal start` is a removed tombstone: it\nerrors and points you to `cotal spawn --detach`.)\n"
62536
+ "body": "# `cotal` CLI reference\n\n> **Reference**: describes the TypeScript reference implementation (the `cotal` CLI), not the wire contract. \xB7 **For:** operators \xB7 **Wire contract:** [SPEC](../SPEC.md)\n\n`cotal` is the operator command line for the reference implementation: bring a mesh up, mint\nidentities, launch agents, watch what they do, and tear it all down. It is a thin client over the\nwire contract: the normative subjects and schemas live in the [SPEC](../SPEC.md); this page is\nlookup material for the commands, not a walkthrough; if you are new, start with\n[Getting started](getting-started.md).\n\n## Running it\n\n```bash\nnpm install -g cotal-ai # puts `cotal` on your PATH (needs Node 22+)\ncotal --help # every command, grouped\ncotal --version # cotal-ai version + each installed extension's (also `cotal -v`)\ncotal <command> --help # one command's flags and usage\n```\n\n`npx cotal-ai <command>` runs it without a global install; in a dev clone, `pnpm cotal <command>`\nruns it through `tsx` with no build step. Bare `cotal` prints help. Every command generates its own\n`--help`, usage, and shell completion from its declared flags.\n\nAn undeclared flag is a usage error, and so is a flag given more than once unless it is\nrepeatable, as `--opt` and `down --session-store` are. The command prints the error and its help,\nexits 1, and does not run.\n\nCommand output, including error lines on stderr and the guided `setup` and `meshes add` prompts,\nis colored only when stdout is a terminal, so piped or redirected output is plain text. A non-empty\n`NO_COLOR` turns color off on a terminal too. `FORCE_COLOR` turns color on even when output is\npiped, unless it is `0` or `false`, and it takes precedence over `NO_COLOR`.\n\nCommands come from the surfaces the binary composes: the base mesh CLI, the manager\n(`supervise`), and the delivery daemon (`deliver`), plus any operator-installed extensions.\n`cotal ext add <npm-package>` installs any registry providers a package contributes: commands,\nruntimes, and local process lifecycle descriptors. The `web` dashboard and optional manager\nruntimes ship this way.\n\n## Commands\n\n| Area | Command | Purpose |\n|---|---|---|\n| Set up & lifecycle | [`setup`](#setup) | Guided, configure-only setup (installs, seeds personas; launches nothing) |\n| Set up & lifecycle | [`update`](#update) | Reconcile first-party extensions and check or opt into a coherent CLI upgrade |\n| Set up & lifecycle | [`up`](#up) | Start a local mesh (nats-server + JetStream), or boot a whole manifest with `-f` |\n| Set up & lifecycle | [`down`](#down) | Stop the whole stack, selected registered components, or a manifest deploy |\n| Set up & lifecycle | [`backup`](#backups) | Create an offline full-space or registry-only artifact from a preserved cut |\n| Set up & lifecycle | [`clean`](#clean) | Configurable cleanup: purge history (live), or wipe the local store / identity (stopped) |\n| Set up & lifecycle | [`meshes`](#mesh-registry) | List the running meshes on this machine |\n| Set up & lifecycle | [`sync`](#mesh-registry) | Refresh the signed-in account's advertised spaces |\n| Set up & lifecycle | [`use`](#mesh-registry) | Set the default mesh a bare `cotal spawn` joins |\n| Set up & lifecycle | [`status`](#mesh-registry) | Read-only diagnostics for setup, processes, and the selected mesh |\n| Agents & personas | [`spawn`](#spawn) | Launch an agent from a persona (foreground, or `--detach` via the manager) |\n| Agents & personas | [`models`](#models) | List connector model catalogs and variants from the manager |\n| Agents & personas | [`ps`](#managed-seats) | List managed agents and their mesh status |\n| Agents & personas | [`stop`](#managed-seats) | Ask the manager to stop a managed agent |\n| Agents & personas | [`attach`](#managed-seats) | Stream and drive a managed agent's terminal (pty runtime) |\n| Agents & personas | [`input`](#input) | Type one line into a managed agent's terminal without attaching |\n| Agents & personas | [`personas`](#personas) | List, show, edit, create, or remove local personas |\n| Agents & personas | [`supervise`](#supervise) | Run a manager daemon (the agent supervisor / control plane) |\n| Agents & personas | [`service`](#service) | Run the manager as a user service (survives logout and reboot) |\n| Agents & personas | [`runtimes`](#runtimes) | List the agent runtimes the manager can spawn through and whether each is reachable |\n| Agents & personas | [`seats`](#seats) | List the pty seat custodians an earlier Linux manager left, and drain the ones whose agent has exited |\n| Agents & personas | [`reconcile-gate`](#reconcile-gate) | Unfreeze an issuance gate left frozen by a crashed restart when the successor cannot boot-heal it (holder gone, complete CONNZ sweep) |\n| Messaging & watching | [`endpoints`](#endpoints) | List every endpoint in the live presence roster, including infrastructure |\n| Messaging & watching | [`describe` / `invoke`](#endpoint-control) | Resolve a v0.4 service's command surface off the wire; invoke one command by name |\n| Messaging & watching | [`send`](#send) | Send one message, then exit: DM a peer, post a channel, or ask a role |\n| Messaging & watching | [`channels`](#channels) | Inspect or set the channel registry |\n| Messaging & watching | [`history`](#history) | Clear retained message history |\n| Messaging & watching | [`console`](#console) | Live protocol view for a space (TUI, or `--plain` line stream) |\n| Messaging & watching | [`web`](#web) | Browser dashboard (installed as the `@cotal-ai/web` extension) |\n| Extensions & misc | [`linear`](#linear) | Serve the official Linear MCP server as an endpoint agents can call (installed as the `@cotal-ai/linear` extension) |\n| Auth & meshes | [`mint`](#mint) | Mint a creds file for a space (static auth mode) |\n| Auth & meshes | [`login`](#login) | Sign in to a per-user-auth mesh's IdP (once per machine) |\n| Auth & meshes | [`logout`](#login) | Revoke the IdP session and clear the cached login |\n| Auth & meshes | [`actor`](#actor) | Manage a user-auth space's actor ledger (grant / revoke / list) |\n| Auth & meshes | [`doctor`](#doctor) | Credential-health diagnosis and repair (`doctor auth`) |\n| Auth & meshes | [`join`](#join) | Join a space as your own presence (interactive) |\n| Manifest | [`topology`](#manifest-deploys) | Validate and view a mesh manifest's access graph (read-only) |\n| Extensions & misc | [`ext`](#ext) | Install / remove operator CLI extensions |\n| Extensions & misc | [`completion`](#completion) | Print or install shell completion |\n| Extensions & misc | [`feedback`](#feedback) | Send feedback to the Cotal developers |\n| Extensions & misc | [`deliver`](#server-daemons) | Run the server-side Plane-3 delivery daemon |\n| Workflow runs | [`run`](#run) | Operate durable workflow runs: start, resume, list, inspect, answer a checkpoint, check an edited program with migrate |\n| Extensions & misc | [`feedback-intake`](#server-daemons) | Run a self-hosted feedback intake server |\n\nThe manifest modes of `up`, `spawn`, and `down` (`-f <cotal.yaml>`) plus `topology` are covered\ntogether under [Manifest deploys](#manifest-deploys).\n\n## setup\n\n```bash\ncotal setup [--full] [--demo] [--yes] [--skills]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--full` | off | Redo the full guided flow (implies `--demo`) |\n| `--demo` | off | Also seed the guided expert team (`david`, `sven`, `me`) |\n| `--yes`, `-y` | off | Non-interactive accept-all (for agents / CI) |\n| `--skills` | off | Reconcile Cotal skills only through installed connector providers, plus `~/.agents/skills`. Refused with `--full` or `--demo`. |\n\nGuided setup is **configure-only**: it checks prerequisites, invokes installed connectors' declared setup providers, and\nseeds persona files, and it launches nothing (no mesh, no web, no manager). First run gets the\nnarrated flow; later runs print a status card. By default it seeds one `default` persona; the\n`david`/`sven`/`me` team is opt-in via `--demo`. `cotal status` points stale Claude skills and\nout-of-date `.agents` skills at `cotal setup --skills`, not unscoped `setup`. See [Getting started](getting-started.md) and, for\nmaintainers, [setup internals](setup-internals.md).\n\nWhen a mesh resolves, setup seeds that mesh's recorded `.cotal/agents` catalog, the same catalog a\nfollowing `cotal spawn` reads. It prints the absolute destination. On a fresh machine with no mesh it\nuses this folder and says why; when several meshes are available and none is selected, it refuses\nrather than choosing a catalog.\n\n## update\n\n```bash\ncotal update [--self] [--space <s>] [--server <url>] [--creds <path>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--self` | off | If a newer release exists, install that exact validated `cotal-ai` version globally and reconcile through the newly installed binary |\n| `--space`, `--server`, `--creds` | resolved mesh | Select the running manager whose continuity state is reported |\n\nWithout `--self`, `update` keeps the installed first-party surfaces coherent with the running\nbinary: it force-reconciles the four built-in connectors, then reinstalls other `@cotal-ai/*`\noperator extensions at the binary's exact version. Each extension runs in an isolated child, so one\nfailure cannot poison later replays. It then checks npm; a newer binary is an informational notice\nwith `cotal update --self` as the next command, not an automatic install.\n\nAfter disk reconciliation, `update` reads the selected running manager. A machine with no recorded\nmesh has no running manager to observe, so that read is skipped and the command completes. The same\nholds when every recorded mesh is down and none is selected. A remote user mesh, registered with\n`cotal meshes add --mode user`, is named and skipped: its manager runs under another install, so\nthere is no custody on this machine to preserve, and a `legacy` verdict still comes only from a\nmanager this machine read. A recorded mesh that is down is still a\nrefusal when the command selects it, with `--space` or by running inside its project, and so is a\nnamed space that is not running. With several meshes running and no `--space`, `--server` or\n`--creds`, the install is machine-wide, so every running manager is reported in turn, each under its\nspace name, before anything is written; a `legacy` verdict on any of them makes the whole run not a\nhot update. A selector flag still reports one manager. A manager without a\ncustody generation is reported as `legacy`: it cannot preserve its manager-owned PTYs, so the\ncommand says that this is not a hot update and prints `exact`, `fork`, `fresh`, or `drain-only`\nfor every seat. This report sends no stop, preservation-commit, or replacement command.\nIt does not preserve a running PTY on a legacy manager. The built-in pty runtime spawns\nin-process on every platform and reports `legacy`. On Linux it still adopts seats that an earlier\nmanager left under a detached custodian, but it starts no new custodian. An incompatible native\n`@lydell/node-pty` or ConPTY ABI break remains an explicit per-seat maintenance cut.\n\nWith `--self`, the selected running manager is reported before any global install. When a newer\nrelease exists, Cotal then installs the exact version it validated, resolves and verifies that\npackage in npm's global root, then launches that binary with the same `--space` / `--server` /\n`--creds` selection to reconcile connectors and first-party extensions to the new generation. An npx\nor dev-clone invocation therefore installs and continues through a separate global copy; it never\nclaims the already-running process changed. If the binary is current, `--self` performs the normal\nlocal reconcile without reinstalling it.\n\nThird-party extensions are listed with their installed version and recorded spec but are not\nauto-updated in v1. Floating third-party updates require `@cotal-ai/*` peer-range validation and are\na future follow-up. A failed connector/extension install, npm metadata check, or requested global\ninstall is reported and makes the command exit nonzero. Independent extension attempts continue so\nthe output includes every failure; an unavailable npm registry does not undo a completed local\nreconcile, but the command still exits nonzero because it could not establish that the install is\ncurrent.\n\n## up\n\n```bash\ncotal up [--detach] [--open] [--space <s>] [--server <url>] [--channels <path>] [--runtime <name>]\ncotal up --user-auth --idp <url> [--exchange-public-port <n> --exchange-public-url <https://\u2026> [--exchange-trusted-proxy]]\ncotal up --tls-cert <cert.pem> --tls-key <key.pem> # serve broker TLS (both, or neither)\ncotal up --restore <dir> [--restore-only registry] [--accept-missing-source]\ncotal up -f <cotal.yaml> [--dry-run] [--runtime <name>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--server <url>` | auto (free local port) | Listen URL override |\n| `--host <host>` | none | Bind host override for a **fresh** broker boot: an IP or hostname only, never a URL (that is `--server`) and never `host:port` (the port comes from `--server` or its default); a URL or port-bearing value is refused pointing at the right flag. With no `--server`, the broker URL is derived from it, so `--host <addr>` alone is enough to make a mesh reachable at that address; a `--host`/`--server` pair naming different addresses is refused. A wildcard bind (`0.0.0.0`, `::`) keeps a dialable loopback URL. Recorded on the mesh and reused by every later manager launch, so a repair or resume keeps remote [`attach`](#managed-seats) working. A live refresh (`\u2713 mesh already running`) does not rewrite `.cotal/auth/server.conf` or rebind nats; stop the broker, then re-run `up --host` |\n| `--space <s>` | the folder's name | Space name |\n| `--store-dir <dir>` | none | JetStream store directory (recorded; a repair up reuses it) |\n| `--max-file-store <bytes>` | nats-server's dynamic cap | JetStream file storage cap in bytes (a positive integer, no unit suffix). Without it nats-server sizes the store at start as three quarters of the free space on its filesystem. The cap is fixed at broker start: a running broker cannot change it (`cotal down` first), `down --preserve-state` keeps it for the resume, and a resume with a different value is refused. Not accepted with `-f` |\n| `--channels <path>` | `.cotal/channels.json` if present | Channel-registry seed file (JSON). An explicit path that is missing is an error |\n| `--restore <dir>` | none | Restore a completed offline backup before exposing the normal listener |\n| `--restore-only registry` | artifact selection | Restore only the registry component |\n| `--accept-missing-source` | off | Explicit disaster consent when the inode-bound preserved source is absent |\n| `--accept-stale-checkpoint` | off | Explicit consent to resume a seat whose checkpoint was captured outside its recorded recency horizon |\n| `--open` | off (auth) | Unauthenticated dev mesh: no JWT, no ACLs |\n| `--user-auth` | off | Per-user auth: people `cotal login`; connects are authorized against the actor ledger |\n| `--idp <url>` | none | With `--user-auth`: the IdP auth base URL to pin on first enable |\n| `--exchange-public-port <n>` | none | With `--user-auth`: add the public exchange face on this loopback port, for an HTTPS reverse proxy to forward to |\n| `--exchange-public-url <https://\u2026>` | none | With `--exchange-public-port`: advertise the reverse proxy's HTTPS URL in discovery |\n| `--exchange-trusted-proxy` | off | With `--exchange-public-port`: attribute public failure buckets to the last `X-Forwarded-For` hop. Enable only when the listener is reachable solely through a trusted proxy; otherwise the socket address is used |\n| `--detach` | off | Run in the background (stop with `cotal down`) |\n| `--tls-cert <path>` | none | PEM certificate to serve TLS with. Must be given together with `--tls-key`. Before starting the broker, Cotal checks readability, private-key mode, key/certificate match, the validity window, and host coverage. `nats-server` accepts an expired certificate and leaves the failure to clients, so Cotal performs these checks first. The decision is recorded; a later bare `cotal up` keeps serving TLS |\n| `--tls-key <path>` | none | PEM private key for `--tls-cert`. Refused if group- or other-readable (tighten to `600`) |\n| `--file <cotal.yaml>`, `-f` | none | Launch a whole mesh from a manifest |\n| `--dry-run` | off | With `-f`: print the plan, mutate nothing |\n| `--runtime <name>` | `pty` (or the manifest's, with `-f`) | Agent runtime for the mesh manager (`pty` built in; others are installed extensions, explicit-only). Resolved + probed before the broker starts; an uninstalled/unreachable runtime fails loud. With `-f`, overrides the manifest's runtime |\n| `--max-sessions <n>` | 64 | Live-session ceiling for the mesh manager. Each console pane and each `cotal attach` is one session, so size for agents \xD7 panes, not agent count. Recorded on the mesh and reused by every later manager launch, so a repair or resume does not silently drop back to 64. A running manager cannot change it: `cotal down` first, then `cotal up --max-sessions <n>` |\n| `--no-manager` | off | Broker-only boot: start the broker and, in auth mode, the delivery daemon, and no local manager. A refresh under the flag of a mesh whose manager is live refuses rather than keeping or stopping it (`cotal down manager` first). Cannot be combined with `--runtime`, `--max-sessions`, or an agent-declaring manifest |\n| `--rotate-sys` | off | Rotate the space's system account and re-mint its two `$SYS` creds. Needs a stopped mesh; refused with `--open` |\n\n`cotal up` boots a local nats-server with JetStream and, in auth mode (the default), JWT auth and\nper-agent ACLs; `--detach` records the mesh so `cotal spawn` from any directory can find it. With no\n`--server`, it auto-selects a free port if the default address is taken; an explicit `--server`\nstays fail-loud on collision. `--detach` also brings up the control plane (delivery daemon in auth\nmode, then the manager). `--no-manager` is the broker-only mode: it boots\nthe broker (and the delivery daemon in auth mode) and starts no manager, so there is no manager\npidfile to leave stale. A refresh under the flag of a mesh whose manager is live refuses rather\nthan keeping or stopping it: `cotal down manager` first. For a split topology with a manager, wait for `.cotal/manager.<spaceKey>.log` to contain `\u2713 manager up`, then `cotal down manager` on that\nhost and run [`supervise`](#supervise) against the remote broker; see\n[Run a mesh](run-a-mesh.md). `cotal up --detach` prints `\u2713 running in the background:` with\n`manager` listed (pidfile liveness, not a teardown boundary); with `--no-manager` the line lists\nonly what actually started. Ctrl-C on a foreground `up` stops the manager through the same stop as\nbare `cotal down` (see [`down`](#down)), then the rest of the stack, and reports managed agents\nunder the same rule: when the manager stop is refused, Ctrl-C prints the refusal with the reap route\nand leaves the stack running. The `-f` form is a\n[manifest deploy](#manifest-deploys).\n\nA repair `up` on a mesh whose broker died reopens the store its record names, and refuses a\ndifferent `--store-dir` rather than silently opening a second store.\n\nThe generated `.cotal/auth/server.conf` is written on a real broker boot and is not an\noperator-owned config. `--host` changes that file only when nats is actually started. A unit\nrestart that leaves an answering listener in place is a refresh, not a rebind.\n\nOn an existing mesh, `cotal up` reconciles the presence and lease bucket TTLs. It writes a reserved\ncanary and waits for the bucket to expire it before reporting success. If the broker accepts the\nstream update but the backing store does not persist or enforce it, `up` exits nonzero with a TTL\npersistence error instead of trusting the value returned by stream info. A refresh that restores a\nmissing manager says so with its pid (`\u2713 restored in the background: manager (pid N)`); a refresh\nthat finds everything already running prints only the `\u2713 mesh \"<space>\" already running` line. A\nfirst boot starts its manager without the restore line.\n\n\n`--user-auth --idp <url>` starts the space's auth service alongside the broker: the NATS\nauth callout plus its capability-gated local exchange, and optionally the closed public exchange\nface configured by the three `--exchange-*` flags above. The service is torn down with `cotal down`,\nand a re-run of `cotal up` heals a dead service on a running broker. `up` waits for the service to\nfinish binding: while the daemon it launched (or found running) stays alive, the wait extends past\nthe base 15s up to 60s; a daemon that exits is refused at once with \"exited before becoming ready\",\nand one alive past 60s is refused as \"alive and still starting\" (wedged), naming the pid record and\nthe service log. `--user-auth` and `--open`\ncontradict each other and are refused loudly; a running broker cannot change auth mode\nwithout a `cotal down` first. See [identity & auth](identity-and-auth.md).\n\n`--rotate-sys` renews the two `$SYS` credentials (`membership-observer`, `connection-evictor`).\nThey carry a 30-day expiry and nothing re-signs them in place, because the system-account seed is\nnever persisted, so they are renewed by issuing a **new system account** under the same broker\noperator and minting fresh creds against it. A plain re-`up` does **not** do this: it reuses the\nexisting trust record, and its `$SYS` creds along with it.\n\nThe rotation is safe to run on a real space, with one operational cost. The data account, the account\nsigning key, every agent credential minted from it, and the JetStream store are all untouched; what\ndies is the retired system account, and with it any out-of-band copy of the old `$SYS` creds, on every\nbroker that loads the rotated config. The cost is that **earlier full backups stop being restorable**\n(see below), so this is not a no-consequence operation. It needs the broker to restart on the rewritten\nconfig, so it runs as part of a boot:\n\n```bash\ncotal down\ncotal up --rotate-sys --detach # agents reconnect; nothing is re-provisioned\ncotal doctor auth # both $SYS creds healthy again, 30 days out\n```\n\nA rotation is a stopped, fresh boot, and anything that is not one refuses it, all for the same reason\n(the on-disk material and the broker it runs on must never end up on different generations):\n\n- a live mesh, because the running broker would keep serving the retired account;\n- an open mesh, whether that comes from `--open` or from `broker.auth: false` in a manifest, which\n has no system account at all;\n- `--restore`, because reinstating a trust root and superseding it in one command leaves no way to\n say which authority the mesh came up on;\n- an unfinished restore or resume attempt on this root, including one `cotal up` would recover on\n its own, because those paths can adopt a live listener and return without booting a broker;\n- a root that hosts more than one space, because the system account lives in the shared broker\n record and a rotation would retire every tenant's, while the root holds one `$SYS` cred pair\n pinned to one data account.\n\nTwo things to know before you run it:\n\n- **The retirement is config-load-bound.** Old `$SYS` creds are refused by any broker that loads the\n rotated config. A stale `nats-server` still running the *previous* config in memory would keep\n honouring them, so stop every broker for this root first. `--rotate-sys` refuses if this root's\n mesh is recorded as running, if anything unidentified is answering at the address it was given, or\n if the root's pid file names a live (or unreadable) process. Those are Cotal's own ownership\n records, not a scan of the process table: a `nats-server` you started by hand against this root's\n `server.conf` on some other port writes none of them and will not be seen. Do not run one.\n- **It invalidates earlier full backups.** A full artifact binds to the trust chain it was taken\n against, and that commitment covers the operator JWT and the system account. Every full backup\n taken before a rotation refuses to restore afterwards, so take a fresh `cotal backup` once the\n rotated mesh is up. `cotal up --restore` names this case when the data account still matches.\n\nThe commit is not atomic (a trust-record write plus two credential writes), so an interrupted\nrotation leaves the record ahead of the creds. That split is detected rather than silent: every\n`cotal up` on an auth mesh, and every `cotal doctor auth`, compares each `$SYS` cred's issuer against\nthe persisted record and names the retired account. `up` warns rather than refusing, because these\ncreds power the membership graph and live eviction, both of which degrade fail-soft; the mesh is not\nworth taking down over them. Re-running the rotation heals it, at the cost of one generation.\n\nWhile those creds are expired the mesh keeps delivering messages, but the\n[membership feed](delivery-daemon.md) and live connection eviction stay down; `cotal doctor auth`\nand the manager's log both name the credential and this repair.\n\n## down\n\n```bash\ncotal down\ncotal down --with-agents\ncotal down --preserve-state [--store-dir <dir>] [--session-store <dir> \u2026]\ncotal down manager [delivery auth web nats ...]\ncotal down web [--space <name>]\ncotal down -f <cotal.yaml> | --run <id> [--dry-run]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--file <cotal.yaml>`, `-f` | none | Tear down this manifest's deploy |\n| `--run <id>` | none | Tear down one `spawn -f` run by id |\n| `--space <name>` | current mesh | With components: the mesh whose target-addressed components (e.g. `web`) to stop |\n| `--dry-run` | off | Print the manifest teardown or selected components, mutate nothing |\n| `--with-agents` | off | Bare whole stack only: also stop and deprovision every managed agent |\n| `--preserve-state` | off | Bare whole stack only: fence the manager, retain principals and durable state, stop and prove the stack down, then publish `ready` |\n| `--store-dir <dir>` | `.cotal/nats` | With `--preserve-state`: the actual store path (required for a custom store) |\n| `--session-store <dir>` | none | With `--preserve-state`: a harness transcript store directory to capture with every continuation-capable retained seat. Repeatable. No default and never inferred from a connector name; a path that does not exist or is not a directory is refused before anything stops |\n\nBare `cotal down` stops the whole local stack in dependency order and leaves managed agents running\nwhen their runtime lets them outlive the manager. Before signalling the manager it verifies the spare\ncapability of the exact recorded manager, which records what that manager's stop does with its\nseats, and it reports the agents left behind plus `cotal down --with-agents` as the explicit reap.\nWhen the manager had no managed agents, it prints no report.\nThe built-in pty runtime keeps each PTY inside the manager process, so those seats cannot outlive\nit: every manager stop stops and deprovisions them, and `down` reports them as stopped. Every manager\nstop the CLI makes runs this one path: `down`, Ctrl-C on a foreground `cotal up`, the teardown after\nthat `up`'s broker exits, the leftover-manager stop before `cotal up -f`, and the delivery cutover.\nEach holds the manager's stop reservation, so a second stop while one is in flight is refused,\nnames the process holding it, and leaves that stop's `--with-agents` policy in place. Each sends\n`SIGKILL` to a manager still running 15s after `SIGTERM`. Ctrl-C stops the manager first; when that\nstop is refused or the manager's exit cannot be confirmed, Ctrl-C signals nothing else, prints the\nrefusal with the reap route, and leaves the stack running; end it with `cotal down --with-agents`.\n`--with-agents` is a one-shot destructive policy bound to the exact verified manager process\nand the exact live `down` stop reservation; a stale, malformed, crashed, or different stop attempt\ncannot turn a later bare shutdown destructive. If a managed agent cannot be proven stopped within\nthe manager's stop timeout, the manager logs which one, still closes its broker connections and\nconsole listener, and exits with code 1. It does not release its pidfile, liveness lease or\nservice registration in that case, so no successor is handed authority while that agent may still\nrun; the lease lapses on its TTL. Positional component names stop\nonly those self-registered local processes; for example, `cotal down manager` leaves delivery and\nthe broker running, and `cotal down web` is available when the web extension is installed. A\ncomponent that starts target-resolved (the web dashboard) is stopped the same way: `cotal down web`\nresolves the mesh the same way as `cotal web` (registry current mesh first, `--space` to name one), so\nit works from any directory; the other components always stop under the folder you run it in. The\n`-f` / `--run` forms tear down a [manifest deploy](#manifest-deploys) without stopping the whole mesh\nand cannot be combined with component names. Stopping `nats` alone is refused while an unselected\nregistered daemon is still live; include those components or use bare `cotal down`.\n\nA pinned manager with no spare-capability record is not signalled by bare `cotal down` or `cotal\ndown manager`. A current manager always publishes the record, so a missing one means an older\nmanager: one that predates capability reporting, or one whose pty runtime reported that it cannot\ndetach its agents. Stop each managed agent explicitly, then run `cotal down --with-agents` from the\nmesh root to stop the whole stack. An older manager does not understand\nthe reap request, which is why the agents must already be stopped.\n\nBare `cotal down` inventories by pidfile. When this folder's registered broker answers and no\n`nats.pid` records it, the command does not say nothing is running. It names the space and the\nbroker address, says no pidfile records that process, says it will not stop a process it did not\nstart, and exits 1. Stop that broker with whatever started it (an init unit, a container, or the\nhand-run process). `cotal meshes rm <space>` only drops the registration. The probe runs whether or\nnot other owned components were running: they stop and clear their artifacts first, then the broker\nis named. A component stop and `--dry-run` stay pidfile-only and do not probe.\n\n`down` reads each process record once. A component that exits and removes its own record while\n`down` runs counts as having no record, so the stop goes on. Any other failed read is an error.\n\n**Teardown verifies pinned process identity before signalling.** PIDs are recycled by every OS,\nso a recorded pid alone is not a durable target identity. `up` and `cotal web` record each\nprocess's creation identity in a sibling `<pidfile>.identity` pin, which holds the pid and the\nprocess start reported by the OS. Every stop path, including `down` for the broker, web and\nextension components, and the manager, delivery and auth-service stops, applies the same rule. A pin\nthat names a different start means the pid was reused, so teardown refuses and preserves it. A torn\nor unreadable pin also refuses.\n\nThe pidfile and its pin are published by renames, and the pidfile rename is the commit point. Just\nbefore it, the pin holds two lines: the old process's and the new one's. A launcher that dies\nmid-publish therefore leaves the old record or the new one, each checked against its own pin line,\nnever a pidfile without its pin. An old record with no pin is legacy, so its line holds `-` in place\nof the token and it stays legacy until the commit. A CLI older than this change reads a two-line pin\nas torn and refuses.\n\nPublishes of one pidfile are serialized by a lock file beside it, `<pidfile>.publish.lock`, because\nthe launcher and the daemon it starts both publish the same record. The next publisher reclaims a\nlock left by a crashed one. When no start token can be read for the new process, its pin line holds\n`-` in place of the token, which reads as a legacy record, and the publish ends in the legacy shape:\na pidfile with no pin. Teardown, and a daemon removing its own record on exit, take the same lock and\nremove the record only while the pidfile still names the pid they stopped, so a stop that races a\npublish leaves the new record whole.\n\nThe web dashboard claims `web.pid` with an exclusive create, so a second dashboard for the same mesh\nis refused, and writes its pin right after the claim. A stop that runs between the two reads a\nlegacy record. A refused claim calls the record stale, and points at `cotal down web`, only when the\nfile is empty or its pid is proven gone. Content that is not a pid, or a pid whose liveness the\nkernel will not report, is refused as possibly fronting a running process, as `cotal down web`\nrefuses it.\n\nThe pidfile pid and the pin pid are two coordinates. Automatic cleanup follows **proven death of\nthe pidfile target** (ESRCH on that pid): a torn sibling pin does not wedge a dead pidfile pid.\nA torn pairing where the pin names another pid, while the pidfile pid is still live or not proven\ndead, still refuses. Inspect both pids with `ps`. Do not delete `<pidfile>.identity` to force a\nstop; that weakens target-identity protection. Once the pidfile process is dead, rerunning\nteardown clears the stale record automatically.\n\nThe first teardown after upgrading a running pre-pin stack has a narrower guarantee. A live record\nwith no identity pin is signalled after a loud warning that it predates identity pinning. Restarting\nthe component writes the pin, so later teardowns receive full match and mismatch protection. The\nsame warning applies on platforms where no stable start token is available. For a legacy manager,\nbare `cotal down` also warns that agent sparing cannot be verified before it signals. Because the\nCLI cannot establish which SIGTERM handler that already-running binary carries, it never presents\nthe pre-signal seat inventory as confirmed spared; a genuinely older destructive handler may still\nreap those agents. `--with-agents` publishes a one-shot reduced-guarantee handoff bound to the\nrecorded manager pid and the live `.stopping` reservation's inode, then signals unconditionally.\nThat handoff cannot be replayed by a later stop attempt. A pin that exists and does not match the\nlive process still refuses before signal.\n\n`--with-agents` performs the old destructive logical teardown: managed processes stop and their\ncredentials, ACL rows, and delivery footprints are deprovisioned. `--preserve-state` is a different\nmaintenance transition: it stops retained processes while suppressing leave/deprovision cleanup, persists the manager's\nsame-principal resume inventory, stops the entire stack without removing run/auth artifacts, and\npublishes a stable inode-bound cut only after every recorded process is proven stopped and the exact\nrecorded NATS endpoint is unreachable. A missing or stale broker pidfile never counts as stopped. The\nattempt is bound durably before the manager is fenced, the resume document and attempt-bound\n`cut-intent` are fsynced before manager commit, and the manager's commitment itself is journaled\n(`cut-committed`) before any process stops. A retry after a crash at any of those boundaries reuses\nthe exact recorded attempt and finishes the remaining stop and endpoint proofs idempotently, without\nneeding the (by then intentionally dead) manager. A partial cut never publishes `ready`. It cannot\nbe combined with component names, manifest teardown, or `--dry-run`.\n\n**Seat checkpoints.** After the stack is proven down, the cut writes one checkpoint per retained\nseat under `.cotal/maintenance/v1/checkpoints/<attempt>/<seat>/`, and prints the path, the\ncontinuity class and the generation for each. The path carries the preservation attempt because a\ncheckpoint is immutable once sealed: a shared directory would make the second cut in a root refuse\non the first cut's leftovers, and clearing it would destroy an artifact a rollback still needs. The\ncapture happens only at that point because anything earlier races a harness that is still writing\nits transcript and its working tree.\n\nEach checkpoint directory is created 0700, refuses a destination that already exists, and holds:\n\n- `repo.bundle`, the seat `cwd`'s reachable history, anchored on the base commit the record names\n by full object id;\n- `repo.index.diff` and `repo.worktree.diff`, the staging state as two diffs, base to index and\n index to worktree. Two rather than one because a single combined diff restores a mixed tree with\n the right bytes and the wrong index: a source reporting `MM README` would come back as ` M README`;\n- `repo.untracked.tar`, the untracked files in scope;\n- the harness session pointer, when the seat's connector declares one, and the transcript store\n files the operator named with `--session-store`. Each records where the destination puts it back\n as an anchor (the workspace root, the account home, or the seat's `cwd`) plus a relative path,\n because the destination's root and home are its own and the source host's absolute spelling would\n either miss them or write outside them;\n- `checkpoint.json`, written last, after every digest is computed over the bytes that landed.\n\nThe record carries the manager's resume entry unchanged as its first field, then the space, the seat\nname, the recovered `lifecycleUid`, the writer generation the cut was taken at, `capturedAt`, the\nrecency horizon, the applied profile revision, the seat's `git status --porcelain` as the cut read\nit, and the continuity class. Every captured file is\nlisted with its byte size and sha256, so an operator verifies the whole artifact with `sha256sum`\nand `git bundle verify`. No secret values, no operator keys and no source-host launch material\nenter it.\n\nThe generation is the highest one this root has claimed for the seat in `.cotal/auth`. A\ngeneration entry there that is a symlink, a directory or anything else that is not a regular file\nrefuses the cut, as every other auth record does.\n\nThe continuity class is what the connector declares, capped by what the checkpoint carries. A\nconnector declaring session continuation classifies as `exact`, but reopening a session takes both\nhalves, the pointer that names it and the store that holds its transcript. A checkpoint missing\neither one cannot reopen that session, so it is recorded as `fresh` when the connector declares a\nfresh start and `drain-only` otherwise. A pointer with no store is capped the same way as a cut\ncarrying neither, because it names a session whose bytes the artifact does not contain. A class is a promise the destination is entitled\nto act on, so it never describes bytes the artifact does not contain. The transcript store stays an\noperator input: this repository does not know where a harness keeps its transcript, so `exact`\nrequires `--session-store` to name one.\n\nThe recorded status is read under the same selection rule as the untracked set, so it describes the\nstate the captured bytes can reproduce. The destination re-reads it in the promoted tree and refuses\na difference.\n\nThe untracked selection rule is recorded in the record and is\n`git ls-files --others --exclude-standard -z, excluding .cotal/`. It honors `.gitignore`, so an\nignored file the seat needs does not travel and has to be moved separately. The `.cotal/` exclusion\nis a secrecy boundary rather than a size one: when a seat's `cwd` is also the mesh root, the control\ndirectory is untracked, and without the exclusion the broker trust material, the space account, the\nmanager instance identity's private seed and the seat's own credentials would land inside the\nartifact. A checkpoint carries credential references only; the destination resolves that material\nitself.\n\nA seat whose launch options could not be resolved is refused rather than checkpointed, with the\nmanager's own wording: `imperative launch options have no non-secret durable source (<keys>)`. The\nrefusal arrives at prepare time, so the cut stops before any child does.\n\nA delegated seat (SPEC \xA713.17) is refused at prepare time too, with\n`a delegated seat is not resumed by a later manager; stop it before preserving`. A manager stop\nafter a refused cut retires that seat through its retirement path.\n\n## clean\n\n```bash\ncotal clean <history|store|all> --force\ncotal clean restore-attempt --attempt <id> --force\ncotal clean restore-fallback --attempt <id> --force\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | `history`: target mesh |\n| `--dms` | off | `history`: also clear DM history |\n| `--store-dir <dir>` | `.cotal/nats` | `store`/`all`: JetStream store directory |\n| `--force` | none | Required: destructive, no prompting |\n| `--attempt <id>` | none | `restore-attempt`: exact stale pre-commit attempt; `restore-fallback`: matching healthy committed restore |\n\nOne configurable cleanup verb; every target requires `--force`.\n\n- `history` purges the retained message backlog on the **running** broker (channels, plus DMs\n with `--dms`). The same operation as [`history clear`](#history), which stays as an alias.\n- `store` deletes the **stopped** mesh's JetStream store (`.cotal/nats`): streams, durable\n consumers, and messages. This is the reset for stale on-disk broker state, e.g. durables\n minted by an older, incompatible Cotal generation surviving a `down`/`up` cycle.\n- `all` is `store` plus the space identity (`.cotal/auth`), the local creds and markers tied to\n it, any crash residue a normal `down` would have swept (stale pidfiles, `run/`), and the mesh's\n registry entry; the next `cotal up` mints a fresh identity.\n\n`history` needs the mesh up; `store` and `all` refuse while any recorded mesh process is still\nalive or any same-root recorded broker endpoint remains reachable (run `cotal down` first). They\nalso refuse outright on a root that holds accounts for several spaces: the store and the broker\ntrust record are shared by every space on the broker, so both targets would take out all of them\nand no `--space` can narrow that. `down`, `backup` and `up --restore` refuse there for the same\nreason. `cotal status` lists the tenants on such a root. Personas\n(`.cotal/agents`) and logs are never touched. The mesh record now carries a custom\nstore location for `up`'s own repair, but `clean` still takes `--store-dir` itself; `clean` does\nnot read the record. Custom cleanup targets must contain either the Cotal store-generation marker or a\nreal `jetstream/` store directory; filesystem roots, project roots, and Cotal auth/maintenance trees\nare always refused.\n\n`store` and `all` also refuse every maintenance journal state. After a healthy committed restore,\n`restore-fallback` is the only supported way to remove the recorded unchanged old-store inode; it\nnever deletes the active target, requires both the exact attempt id and `--force`, and retires the\ncompleted restore journal so a later `down --preserve-state` can start a new backup cycle.\n\n## Backups\n\n```bash\ncotal down --preserve-state [--store-dir <dir>]\ncotal backup create <dir> [--only full|registry] [--store-dir <dir>]\ncotal up --restore <dir> [--restore-only registry] [--accept-missing-source]\n```\n\nBackup is offline-only. It requires the stable `ready` record from `down --preserve-state`, an exact\nstore match, no live recorded process, and an unreachable exact endpoint from the recorded cut.\nThat endpoint is probed immediately before cloning, so a live broker with a missing or stale pidfile\nis still refused. It claims the cut, reflink/copies the stopped source to a\nprivate attempt clone, and opens only that clone on a random loopback bootstrap broker with an\nindependent parent/deadline watchdog. It validates the canonical stream and pull-consumer inventory,\nwrites native snapshots with consumers excluded, and stores conservative contiguous ACK-floor\ncheckpoints separately. The presence bucket is memory-backed, so it does not survive the cut and\nthe clone may lack it. Every other stream must be present. The original store is never opened by\nthe backup broker, and the stack is not restarted implicitly. Artifact destinations must not overlap\nthe preserved source or maintenance\nattempt tree. Restore artifacts and targets likewise cannot nest inside or contain each other, the\npreserved source, or the maintenance attempt tree.\n\nStopped client-managed KV ordered consumers are ephemeral read residue, not backup state. Backup\nignores only the pinned client's exact stopped shapes: ordinary last-value watchers and the\nwhole-bucket scanner that uses all-history delivery to collapse concurrent tombstones. A bound\nconsumer or any lookalike with a different filter, inbox, lifetime, or other config is still refused.\n\n`full` is the default and indivisible: channel registry, CHAT/DM/TASK/INBOX/DLV, ACL, MEMBERS, and\nvalidated durable checkpoints. `registry` is the sole partial artifact. Presence, derived membership\nfeed, leases, native ephemeral/history consumers, credentials, keys, tokens, owner secrets, and actor\nledger files are excluded. `full` means every transferable message and registry stream, not every\nJetStream resource: endpoint submissions/facts/events/timers/workflow state, contract artifacts, and\nthe records/auth/session stores are nonportable control state. Restore recreates those streams empty\nwith their canonical configs before exposing the normal listener, so active endpoint runs,\nlifecycles, and sessions do not cross a backup. Artifacts are exclusively created `0700`;\nsnapshot/checkpoint files and\nthe manifest are `0600`; `manifest.json` is written last with exact sizes and SHA-256 values. The\ndirectory is trusted operator input: hashes detect corruption, not malicious rewriting.\n\nRestore validates and stages the exact allowlisted artifact bytes before moving or creating a store.\nIt requires the same space and existing trust state. The whole pre-commit window holds a journaled\nliveness claim (coordinator, watchdogs, brokers, absolute deadline): ordinary `up` and a repeated\n`up --restore` refuse while the claim is live, and a stale attempt is recovered only after the\ndeadline has elapsed and every recorded owner is proven dead. A retried `up --restore` handles this\nautomatically; an operator can also recover it explicitly with `cotal clean restore-attempt --attempt <id> --force`. Nothing\never rolls back a live attempt. A registry-only artifact restores as registry-only whether or not\n`--restore-only registry` is passed; omitted infrastructure is always created and the exact\npost-restore stream inventory is asserted before commit intent. Ordinary `up` from a preserved cut\nresumes only the exact recorded source store and runtime; a contradicting `--store-dir` or\n`--runtime` fails in preflight.\n\n**Admitting a seat checkpoint.** An ordinary `up` from a preserved cut admits that cut's seat\ncheckpoints before it journals the resume attempt and before any process starts, so a refusal costs\nnothing. Three gates run in order, each naming what it saw.\n\n1. *Integrity.* Every file the record names must be present, a regular non-symlink file, the\n recorded byte size and the recorded sha256, re-stat'd after the read so a file that moved is a\n refusal. Failure here consults no other gate.\n2. *Identity.* The recorded space must match, the recorded `lifecycleUid` must not belong to a live\n incarnation, and the profile revision must match this host's or be resumed under deliberately\n this host's. A differing revision is refused with both digests and the remedy, and there is no\n override: the checkpoint carries the recorded digest and not the config bytes, so nothing could\n run the seat under the recorded revision, and the manager re-digests the same file and refuses\n drift on its own. This gate has no blanket override, which is the only reason the next one may\n have one.\n3. *Recency.* `capturedAt` is compared to this host's clock against the horizon the record carries.\n Inside it, the seat resumes. Outside it, `up` refuses and prints the capture instant, the clock\n reading and the horizon; `--accept-stale-checkpoint` admits it anyway and the exercised consent\n is printed with the actual age. An unreadable `capturedAt` is refused with no override, because a\n freshness gate that fails open is not a gate.\n\nCustody transfers only after all three pass. The destination claims the recorded generation plus one\nby exclusive create, before it launches anything. A lost create means another destination is already\nclaiming that seat, and it refuses with `seat-writer-generation-create-lost` rather than adopting\nthe winner and becoming a second writer. The recorded `lifecycleUid` is reused and never minted, so\nthe resumed seat binds the same lifecycle-keyed durables.\n\nAdmission is reconciled against the inventory the resume is about to hand the manager, and that\nreconciliation finishes before the restore moves a single tree. A checkpoint whose recorded\n`lifecycleUid` is not the one the retained inventory carries describes a different incarnation of\nthat seat, and it refuses with both uids while every live working tree is still untouched and no\ngeneration is claimed. A retained\nseat with no admitted checkpoint refuses the resume by name: an absent checkpoint directory and an\nabsent record are indistinguishable from a seat that was never checkpointed, and a seat that starts\nwithout passing the gates has claimed no generation. `--accept-stale-checkpoint` is recorded in the\nresume journal with the seat, the capture instant, the admitted age and the horizon, so the consent\nsurvives the terminal it was typed into.\n\nThe whole admission is all or nothing. Coverage is settled first, then every gate runs over every\ncheckpoint, and only then is any generation claimed. A refusal at any point leaves every generation\nunclaimed, including a lost exclusive create during the claim itself: the claims that attempt made\nare removed before the refusal is raised, by the exact paths it wrote, so a generation another\ndestination holds is never touched. A claim is a create that can never be made again, so a refusal\nthat left one behind would consume the retry over the same checkpoint set.\n\n**Restoring a seat checkpoint.** Once every gate has passed over every checkpoint, and before a\nsingle generation is claimed, `up` puts each admitted seat's captured bytes back. A refusal here\ncosts nothing for the same reason a gate failure does: no claim has been made and nothing has\nstarted.\n\nA restore never moves or replaces the destination's own control directory. A checkpoint excludes\n`.cotal/` by design, so a seat whose `cwd` holds one, which is the layout an operator gets by\nrunning `up` and `spawn` in a single directory, is refused before anything is staged: promoting a\ntree that cannot contain `.cotal/` over that `cwd` would carry this host's live trust material and\nmaintenance state away with the superseded tree. The refusal names the control directory it found\nand the remedy, which is to give the seat a working tree that is not a workspace root.\n\nEach seat is staged beside its own `cwd`, in `<cwd>.incoming`:\n\n1. every recorded digest is verified again over the files as they are now;\n2. the bundle is cloned into `<cwd>.incoming`, which is refused when that path already exists;\n3. the recorded base commit is verified in the clone and checked out detached, so a bundle that does\n not contain it stops the resume instead of continuing against a different history;\n4. the index diff is applied with `--index` and the worktree diff without it, both `--binary\n --allow-empty`. That order is what puts staged content back in the index rather than only in the\n worktree, and `--allow-empty` is why a seat with a clean tree is still restorable;\n5. the untracked archive is extracted.\n\nEvery seat stages before any seat is promoted. Promotion moves an existing `cwd` aside to\n`<cwd>.superseded.<timestamp>` and renames the staging directory into place, then puts the session\npointer and store files where the destination's connector reads them, then re-reads\n`git status --porcelain` in the promoted tree and compares it to the status the checkpoint recorded.\nA restore that applied without error and produced a different index is a refusal, not a warning. The\ntwo renames are the only steps that touch the path the seat will use, so a failure anywhere leaves\nevery seat's live `cwd` as it was.\n\nThe rename itself claims the superseded name, and a taken name gets a numeric suffix. The timestamp\nhas one-second resolution, so two promotions of the same seat within one second compute the same\npath; a rename onto a name that already holds a tree fails on every platform, and that failure is\nread as taken. Nothing creates the name ahead of the move, because Windows refuses to rename onto an\nexisting directory at all. A superseded tree is the thing that rename exists to keep.\n\n`git` and `tar` run as child processes with argument arrays, never a shell string.\n\nA leftover `<cwd>.incoming` refuses the resume by name. A staging directory from a failed run is the\nonly record of what failed, so nothing removes one automatically: inspect it, remove it by hand, and\nresume. A pre-existing `cwd` is renamed rather than deleted, so a wrong checkpoint costs a rename\ninstead of a tree. When a promotion fails, the renames that attempt made are undone and the staging\ntree is left where it is, as the evidence for what did not verify.\n\nA session pointer whose recorded `sessionId` is not the one the retained inventory reopens is\nrefused before anything is cloned. A session file already present at its destination is judged by\ncontent: bytes equal to the recorded digest are already restored, and different bytes under the path\nthe connector is about to read are refused with both digests rather than clobbered.\n\n`up --restore <dir>` reaches the same admission and the same restore, after the store is restored\nand validated and before commit intent is journaled. A registry-only restore resumes no seat, so it\nadmits and restores nothing.\n\nOne limit is worth stating plainly. The writer generation is claimed by exclusive create inside one\nworkspace root, so it fences two resumes on the same host and does not fence two independent\ndestinations: copy a checkpoint to two roots and both claim the same successor. A real cross-host\nfence needs a coordinate neither root owns.\n\nAuthenticated restores validate the complete\nspace trust bundle before staging, including nkeys, seed matches, JWTs, signers, and space binding;\nfull restores commit to the validated operator, system-account, data-account, and active-signer root\nchain in addition to the static/user authority fingerprint. Because the system account is part of that\ncommitment, a [`cotal up --rotate-sys`](#up) makes every full artifact taken before it unrestorable\nagainst this root: take a fresh full backup after each rotation. The composed commitment is revalidated\nimmediately before store mutation and never includes secret seeds. Restore never creates fresh auth.\nSame-path restores atomically retain the old\nsource at the journaled fallback path; alternate targets retain it in place; a missing canonical\nsource needs explicit `--accept-missing-source`. Quarantine and target restores use current canonical\nconfigs on isolated random-loopback brokers, never expose native snapshot consumers, and publish a\ncommit-intent immediately before the normal listener starts. Archive bytes never instantiate the real\ntarget: after quarantine validation, every stream is re-snapshotted from the validated quarantine\nstate into attempt-owned sanitized files, and the target is restored solely from those. Before that boundary, failure rolls back\nthe attempt-owned target; after it, ambiguity preserves both stores and records forward-repair\nrecourse. The cooperative maintenance lock excludes Cotal commands, not arbitrary raw NATS processes.\n\nBootstrap brokers in every auth mode, including open, mount the store under a local account with\nrandom operation-specific logins only, each carrying the exact per-phase subject permission matrix;\nnormal static credentials and user-auth sentinel/bearer connections are rejected, and no auth\nservice or callout starts. Open mode differs only in its account label, never in authority. Inventory, each stream snapshot,\nrestore initiation, exact upload id, validation, and each checkpoint recreation use separate exact\nauthorities. Every checkpoint carries the source stream's message/first/last sequence state and must\nmatch its snapshot record before mutation; core then derives and validates the only allowed start\npolicy. TASK is not a CLI exception: the same core checkpoint API recreates its canonical `DeliverAll`\nWorkQueue durable because acknowledged tasks are absent from retention and NATS forbids a\nstart-sequence policy there. Registry-only restore creates every omitted canonical stream and transient\nbucket on the isolated target before the normal listener is exposed. It deliberately does not resume\nretained agents or recreate their DM/DLV/TASK/ACL state; their identity material stays retained and\nstopped rather than being reprovisioned into a partial restore.\n\nAfter listener readiness, the manager starts attempt-bound, validates retained credentials/tokens\nwithout granting or reprovisioning, and resumes the exact persisted principals under cleanup\nsuppression. Registry-only restore uses the same flow with an empty agent set. On a user-auth mesh\nthese manager calls run as the logged-in operator's `cli` actor, the caller the preserve cut used, so\nthat actor needs a current `admin` grant. `commitResume` is an\nidempotent validation barrier only: success must be `awaitingFinalize` with an attempt-bound 64-hex\ncommit token and does not release suppression. Under the workspace lock, the CLI first fsyncs that\nexact evidence as `manager-committed` (restore) or `resume-committed` (ordinary resume), then calls\ntoken-bound `finalizeResume`; only an `active` response for the exact token releases suppression. The\nCLI records the same token in finalization evidence before a restore becomes `active`, or before an\nordinary resume retires and consumes the marker. Re-entry from either committed state skips the prior\nidempotent activation/commit phases, retries finalization with the durable token, and finishes the\nworkspace transition. Failure before finalization preserves the committed state and cleanup\nsuppression; it is not rewritten through a degraded transition. Re-entry between any two earlier\nboundaries reuses the same attempt and may retry the idempotent phases without deleting retained state. A missing or\nchanged per-agent dependency is a named fail-closed result; the journal becomes degraded and remains\navailable for forward repair. A retry from `resume-intent`,\n`resume-active`, or `resume-degraded` reuses the same attempt and inventory after the prior listener is\nproven stopped. A retained agent the lost manager already launched can still be running, for example\nin a tmux window, while the journal reads `resume-intent`. On a static mesh the replacement manager\ncloses that seat through the reference the lost manager recorded on the agent's slot, waits for the\nprincipal to leave presence, and launches it again. A live principal with no such record, or one that\nstays live after the seat is closed, is refused. Every normal restore listener has an unguessable\nattempt-bound NATS server name. The CLI fsyncs its exact name/nonce, canonical endpoint, process owner, and generation-bound target identity\nimmediately after spawn. Re-entry accepts a surviving listener only when its INFO server name, live PID\nrecord, endpoint, and target identity all match that proof; degraded restore repair then moves through\nthe guarded workspace transition only after manager commit. If an uncommitted bound owner is provably\ndead, recovery retires that exact proof under the maintenance lock and binds a fresh listener for the\nsame attempt, endpoint, and target with a new nonce and server name. A live foreign/mismatched listener\nor ambiguous owner is preserved and refused, never adopted by reachability alone. A reconstructed\ncommit/degraded attempt without either the exact bound proof or a durable dead-listener replacement\nrecord fails closed even when the recorded port is free. A later ordinary startup may pass an `active`\nrestore only when its details prove manager commit and its exact recorded listener is dead.\n\n## Mesh registry\n\n```bash\ncotal meshes [--json]\ncotal meshes add # guided, on a terminal\ncotal meshes add <space> --server <url> [--root <dir>] [--mode auth|open|user] [--tls] [--force]\ncotal meshes add <space> --mode user (--user-auth-file <bundle.json> | --from <https url>)\ncotal meshes rm <space> [<space> \u2026] [--force]\ncotal sync [--idp <auth base URL>]\ncotal use <space>\ncotal status [--space <s>] [--server <url>] [--components]\n```\n\n`meshes` lists the meshes this machine knows; a `*` marks the `current` default a bare\n`cotal spawn` joins. Entries learned from a signed-in account are marked `discovered`. Their\nregistration trust is stored under the account's private auth state, and the registry contains no\nsession token or sentinel credential bytes. Commands resolve the catalog `slug`; a different human\n`name` is rendered only as a label.\n\n`meshes --json` prints one JSON object per recorded mesh per line: `space`, `server`, `mode`,\n`root`, `default` (the `*`), and `origin` (`up`, `manual`, or `catalog` for a discovered entry). A\nlocal or hand-registered entry also carries `offline`. A discovered entry is never probed, so it has\nno `offline` field. `tlsRequired`, `events: \"required\"` and a discovered entry's `catalogName` appear\nonly when the record has them. An empty registry prints nothing and exits 0. The note about a default\nthat matches no record goes to stderr, so stdout carries only rows, on a first run too. The table is\npresentation and is not a stable parsing target. `meshes add` and `meshes rm` refuse `--json`.\n\nA registry record this build cannot use is refused by name, never rendered and never skipped. One\nthat does not parse, or is missing a field every consumer reads (`server`, `mode`, `root`, `ts`,\n`space`), makes every registry command exit 1 with the file's path and what is wrong with it.\nRemove the file or restore the record; nothing repairs or invents a field for you.\n\nAn IdP may advertise a same-origin space catalog during login. Cotal reads the complete snapshot and\nadds every valid registration without a separate `meshes add`. A snapshot younger than five seconds\nis used without a request. After that, commands that resolve a mesh target conditionally refresh the\nsaved catalogs. An operation targeting a discovered space refreshes only that space's account and\nrefuses if that account fails. Operations targeting local or manually registered meshes refresh every\naccount, print one warning for each failure, and continue. `cotal status` refreshes every account,\nnever refuses on a refresh failure, and lists each account as `fresh`, `updated`, `not-modified`,\n`no-catalog`, or `failed` with its error. `cotal sync` bypasses freshness and reports added, changed,\nremoved, unchanged, and name collisions. `--idp` limits it to one signed-in account. It never connects\nto a broker.\n\nThe registry is updated under the same lock that guards the catalog cache, so a command never lists\na discovered space set that another command is still writing. The cache records a fetched snapshot\nas not yet applied before the first registry write and as applied after the last. If a command dies\nor is stopped in between, the next command applies that snapshot again before it can use it, with\nno request inside the freshness window.\n\nThe shared dispatcher applies this preparation to every command that declares both `--space` and\n`--server` as mesh-target flags, including commands registered by other packages and commands that\ndeclare their own equivalent flag objects. Daemon and startup commands that use those names only as\nconfiguration explicitly opt out. Registry-local `meshes add` and `meshes rm` never refresh a catalog.\nWhile the registry holds a record this build cannot use, the preparation neither refreshes nor\napplies a catalog, so the command's own checks run first. A snapshot left unapplied is applied by the\nnext preparation after the record is restored or removed. A command that resolves its target through\nthe registry still refuses the record by name.\n\nRun on a terminal with the space or `--server` missing, **`meshes add` is guided**: it asks for the\none thing that cannot be derived (the broker URL), probes it, and tells you what answered - open or\nrequiring credentials. It then offers the spaces your `--root` already holds credentials for, states\nthe mode as a fact about that broker rather than asking, and shows the exact record before writing\nanything. A broker that does not answer, or a space name already registered, becomes a choice rather\nthan an error. Anything you pass on the command line is taken as given and not asked again. Without\na terminal - a script, an agent, CI - nothing prompts and the flag form's errors stand\n(`COTAL_NO_PROMPT=1` forces that too).\n\n`cotal up` and `cotal down` maintain their own records. `meshes add` registers a mesh they cannot\nspeak for: one running on another machine, a shared broker, a hosted space. `--root` is the folder\nwhose `.cotal/auth` holds that mesh's credentials and whose `.cotal/agents` holds its personas.\nThe default is the project you run it in. The registry stores that path, never a secret. `--mode`\ndefaults to `auth` when the root holds the space's account record and to `open` otherwise. The\nbroker is probed before anything is recorded, so a wrong address, or credentials that mesh will\nnot accept, fails here instead of at the first `spawn`; `--force` records without verifying (and\nreplaces an existing record).\n\nA hostname or public address is registrable only when the connection will **require TLS**. Pass\n`--tls`, or use a `tls://` URL. The scheme is recorded as enforced intent, so every later dial\nthrough the record demands the handshake (and `meshes add tls://\u2026` against a plaintext broker is\nrefused at registration). Without required TLS the fence admits loopback and private-overlay\nliterals only. RFC1918 addresses are refused in both modes because a cafe LAN is private but does not belong to you.\n\nA **user-auth** mesh registers from supplied pinned trust, never guessed: `--user-auth-file`\ntakes the bundle exported where the mesh runs; `--from` asks before it dials the address at all,\nthen fetches the `/.well-known/cotal-mesh` discovery document under that address (HTTPS only; a URL\nthat already ends in that path is fetched as given), displays the pins, and asks again before\nadopting them. Neither fetch follows redirects: a 302 can move a pinned fetch\nonto plaintext or onto another host, so it is refused rather than followed, and the pinned\nexchange must itself be an `https://` URL, except for an exchange on this machine, where plain\n`http://` is accepted for a loopback *literal* (`127.0.0.1`, `::1`, any spelling of them) but not\nfor `localhost`, which is a name rather than an address. Registration verifies that the exchange\nanswers `/health` and `/jwks` as the pinned issuer. It also verifies that the broker refuses a bare\nconnect; that auth-required refusal is the pass. The sentinel credentials land in a 0600 file under\nthe entry's root; the registry records only the path.\n\n`meshes rm` drops records. It never stops a mesh. For a mesh running on this machine `cotal down`\nis the right verb, and `rm` says so unless you pass `--force`. A hand-added record is removed by\n`meshes rm`, by an `add --force` replacement, or by a `cotal up` that actually starts the broker for that same space, server and root, which becomes that\nmesh and so takes the record over (a `cotal up` for that space anywhere else refuses instead).\nNothing that merely *infers* a record is stale from a dead broker touches it: an\nunreachable broker is listed `offline` and stays, whether `cotal up` or `cotal meshes add`\nwrote the record; a foreground `up` whose broker exits unexpectedly keeps its record the same way.\nA bare command does not treat that offline record as a running mesh;\nname it with `--space` to restart it. `cotal down` / `cotal clean all` still drop an `up` record for the project\nthey tear down; a hand-added one they leave alone even when it shares a root, because nothing\non this machine could write it back.\n\nA discovered entry belongs to the normalized IdP origin and proved subject that supplied it. Local\nteardown, cleanup, and liveness pruning do not remove it. A manual or locally started entry with the\nsame name wins and remains untouched; that discovered name is reported as a collision. Logging out\nremoves only the discovered entries owned by that account.\n\n`cotal meshes` and `cotal status` print `events: required` for a registration carrying\n`policy: { events: \"required\" }`. On that space, foreground spawn, detached spawn, manager starts,\nand interactive `join` cannot opt out or join without an event plane. `--no-events` is refused with\nthe space named. A connector without an event plane is refused with both the space and connector\nnamed. A session whose own grant omits `events.<owner>.<actor>` is refused before joining and the\nmessage names a full-row `actor grant` repair. A running seat whose event plane stops for good on\nthat space stops too. A seat the manager resumes from a preserved cut meets the same rules as a fresh\nspawn: a seat retained with `--no-events`, or on a connector without an event plane, is refused, and\na resumed seat carries the requirement in its launch material.\n\n`use <space>` sets that default; the selection applies from every directory,\nincluding inside another mesh's project. `status` is a read-only report: machine prerequisites\n(starting with the installed `cotal-ai` version), the installed extensions and their versions, this\nfolder's `.cotal/`, the recorded meshes, and a live snapshot of the selected mesh (roster, channels,\nmembership feed). Stale Claude skills and out-of-date `.agents` skills recommend `cotal setup --skills`,\nnot unscoped `cotal setup`. `status` takes `--space` / `--server` to pick the mesh to inspect; it starts\nnothing. The manager row asks the service endpoint once: a live process that does not answer is\n`not serving`, and a probe that could not be made leaves the row `running \xB7 service unchecked`.\nA process row whose PID record exists but cannot be read, or exists under both its current and a\npre-upgrade name, reads `pidfile unreadable` with the error, and the other rows still print. A live\nmanager whose delivery-aware marker cannot be read keeps its row and names the failure as\n`delivery-aware marker unreadable` with the error. The `Web process` row\nprints the address the selected mesh's dashboard recorded in `web.session` once it was listening,\nwhile the PID in its `web.pid` is alive. A `web.pid` or `web.session` that exists but cannot be read\nis named on the row with the error. Otherwise it reads `down`, or `not installed` without the web\nextension. Status and the `cotal setup` card count the web extension as installed only when a\npackage in the extensions manifest provides `web` and that package is on disk. A web package record\nthat cannot be read is named on the `Web extension` row and on the card's web row, after the\ndashboard's address when it is listening and beside an unreadable `web.pid` or `web.session`. The\n`Web process` row still prints, and reads `down` unless the dashboard is listening.\n\nIf a refresh fails, `status` may still show the kept catalog bytes for diagnosis. It labels them\nstale with the last successful snapshot timestamp and the refresh error. It never calls that state\nsynchronized or online. If a selected discovered space vanishes from a successful snapshot, the\nselection is cleared and the command reports that no default is selected.\n\nFor a user-auth mesh the selected-mesh section reports the login `status` works as: the signed-in\nsubject when this machine holds a cached session for the entry's pinned IdP, or the exact `cotal\nlogin --idp <url>` line when it does not, with no network round trip either way. A locally\nprovisioned space also shows the actor grant row; a discovered or registered remote entry reports\nthe grant as not checkable on this machine, because the ledger runs where the space was\nprovisioned. `--components` on a user-mode target probes as that same signed-in login (`ps`'s\ncredential), never a static mint; when the login cannot supply a credential, the row says why\ninstead of printing the broker's refusal of an unauthenticated probe.\n\nPersona rows name the catalog they describe. If this folder and the selected mesh use different\ncatalogs, status names both and marks which one spawn launches from. A green `default` means the file\npasses the same agent-file loader spawn uses; a present but invalid file is reported as invalid.\n\n`cotal status --components` adds a fail-loud per-component health pass. It reads **each\ncomponent's own control surface**, rather than treating a PID, a lease, or a successful probe of a\nsibling as proof that the component serves. It prints one of `serving`, `absent`, `not-serving`, or\n`refused` for each component and exits `0`, `1`, `2`, or `3` respectively (the highest observed\nstate wins):\n\n- **manager**: local PID record, its liveness-lease holder and PID, then the manager's own typed\n `status` service reachability from this host. Manager builds that do not report static\n reconciliation say `static reconciliation not reported by this manager build`; the line stays\n visible even when the manager is otherwise `serving`.\n- **delivery**: local PID record, its ready lease (`ready` is the daemon's own bound-control\n signal), and the latest `renewal.<spaceKey>.json` adoption verdict, the record of the space the\n command was asked about, keyed per space the way the pidfiles are. A re-signed credential and a\n broker-accepted adoption stay distinct facts. A root-only `renewal.json` left by an older build\n names no space and is never read as any space's verdict (`doctor auth` names it as a leftover).\n- **web**: local PID record, then the `/api/meta` response at the address the dashboard recorded in\n `web.session` once it was listening, which must name the same PID. The probe presents the\n readiness nonce recorded beside that address, the one credential the dashboard accepts on\n `/api/meta`. A live PID with no recorded address (the dashboard is still writing it, or an\n earlier build started it), or an unrecognizable process record, is `refused`, not a green\n default-port guess. A `web.session` that exists but cannot be read is also `refused`, and the row\n names the read error.\n- **broker**: the registered mesh URL dialed from this host with its recorded TLS requirement.\n\n`absent` means Cotal has no live local component record (or has a stale record); `not-serving`\nmeans the component record is live but its service/readiness surface did not answer or is not ready.\nThose are intentionally separate exit cases. A failed or unreadable probe is `refused`, never an\nabsent component or a clean zero. A PID record that exists but cannot be read, or exists under both\nits current and a pre-upgrade name, refuses only its own row. A record that its component removes\nwhile the pass runs reads as `absent`.\n\n## spawn\n\n```bash\ncotal spawn [<persona>] [--detach] [--name <n>] [--agent <a>] [--model <m>] [--variant <v>] [--prompt <text>] [--cwd <dir>]\ncotal spawn -f <cotal.yaml> [--dry-run]\n```\n\nFor a foreground spawn onto a remote user-auth mesh, a launcher may supply a one-time enrollment\ninstead of a cached human login. Prefer a private file:\n\n```bash\nCOTAL_ENROLLMENT_FILE=/run/secrets/cotal-enrollment \\\n cotal spawn --config ./seat.md --space main\n```\n\nThe file contains only the enrollment URL, ending with at most one line terminator, and must be\nmode `0600` on POSIX. An orchestrator that cannot mount a file may set `COTAL_ENROLLMENT_URL`\ninstead; that value is redeemed byte for byte, so a trailing newline in it is refused. Setting both\nis refused. Enrollment input\nrequires `--space` and applies only to a foreground persona spawn. If the mesh is not registered yet,\nthe enrollment response must carry the stock user-bundle fields and the command needs\n`--config <persona-file>` because there is no local remote-mesh persona catalog to read. The client\nredeems the URL once, registers the returned mesh material, exchanges the returned actor token at the\npinned auth service, and removes both enrollment variables before starting any child process.\n\nA cached login for the same IdP and an enrollment are conflicting proofs, so the command refuses\nrather than choosing one. An invalid enrollment never falls back to login provisioning. Unknown,\nexpired, revoked, and already-used enrollments all produce one response: ask the owner for a fresh\none. See [Enrollment redeem](identity-and-auth.md#enrollment-redeem) for the HTTP contract.\n\nA runtime that starts a managed seat outside the manager's filesystem hands the child a managed\nhandoff instead: one `0600` file named by `COTAL_MANAGED_HANDOFF_FILE`, carrying the lifecycle the\nmanager already enrolled. The runtime builds the command with `delegatedSeatCommand`:\n\n```bash\nCOTAL_MANAGED_HANDOFF_FILE=/run/seat/handoff.json \\\n cotal spawn --config ./seat.md --space main --name <actor> --agent claude \\\n --expect-owner <owner> --expect-lifecycle-uid <uid>\n```\n\nThe `cotal` entry reads the file, deletes it and drops the variable before it parses flags, prints\nhelp or loads extensions, so every outcome leaves no file. The variable is read under any letter\ncase; spellings that name different files are refused after every one of them was deleted. The\nspawn then refuses a malformed handoff, or one whose space, owner, actor or lifecycle UID differs\nfrom `--space`, `--expect-owner`, `--name` and `--expect-lifecycle-uid`, before any broker\nconnection or exchange request. Every refusal on this path names the field and never a value from\nthe handoff. The registration's server, exchange and enforcement checks, the local state this\nmachine keeps for the space (its mesh record, user-auth state and agent secret files), target\nresolution, the policy refresh, the broker preflight and the agent auth preflight quote the space,\nthe server, the exchange URL, the actor or a path named for one of them in their own diagnostics and\nin the filesystem errors under them. For a handoff each prints one fixed sentence that names the\nfield and the phase instead, whether its check fails or an error is thrown. When the agent auth\npreflight's rollback then fails to remove a secret or file, that sentence is followed by the names of\nthe cleanup steps that failed, without their errors. The event-plane policy\nrefusals name the handoff's space field. An actor outside `[A-Za-z0-9_]` and a space that cannot\nname local state, such as `..`, are refused as malformed before any plane. A handoff conflicts with\nthe enrollment variables, `--detach`, `-f` and `--creds`, and needs `--config <persona-file>`. From\nthere it runs the enrollment consumer above without redeeming anything. See\n[Delegated seats](embedding.md#delegated-seats-outside-the-managers-filesystem).\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | resolved mesh | Target space |\n| `--server <url>` | registry entry | Broker URL override |\n| `--creds <path>` | none | Control-caller creds for an off-registry manager (`--detach` only) |\n| `--name <n>` | persona's `name:` | Presence-name override (does not choose the persona) |\n| `--config <persona-or-path>` | none | Persona catalog name or file path; wins over the positional |\n| `--agent <a>` | persona's `agent:`, else `COTAL_DEFAULT_AGENT`, else `claude` | Connector type (`claude`, `opencode`, `jcode`, `hermes`, and so on) |\n| `--role <r>` | persona's `role:` | Role override |\n| `--model <m>` | persona's `model:` | Model override |\n| `--variant <v>` | persona's `variant:` | Model variant override (connector-defined; e.g. OpenCode reasoning tiers) |\n| `--cwd <dir>` | this cwd | Working directory to root the agent at. Refused before launch when the directory does not exist on the serving manager's host. |\n| `--prompt <text>` | none | Initial prompt auto-submitted at start |\n| `--resume <id>` | none | Fork an existing session id into the mesh; only connectors that declare resume support accept it (see [the matrix](connectors.md)). The manager records the source session id, and `ps --wide` shows it. With `--detach --on <instance>`, a Claude session held on this host is carried to that instance first ([Resume a session](connect-claude.md#resume-a-session)); carrying one needs `--on` |\n| `--no-events` | event plane on where supported | Opt out of the session's structured event plane (`--events` only restates the default) |\n| `--share-tools <sel>` | none | Share named operator MCP servers with the agent |\n| `--subscribe <a,b>` | persona's | Channel read-set override |\n| `--allow-subscribe <a,b>` | = subscribe | Read-ACL override |\n| `--allow-publish <a,b>` | deny | Post-ACL override |\n| `--detach`, `-d` | off | Launch via the manager into a detached PTY (reattach with `cotal attach`) |\n| `--on <instance>` | class anycast | With `--detach` only: pin the launch to one manager instance id (the whole id, as `ps` prints it). Refused on a foreground spawn (no manager to pin), with `-f` (a manifest deploy launches through the manager class queue), and when empty |\n| `--file <cotal.yaml>`, `-f` | none | Deploy a manifest onto the running mesh |\n| `--dry-run` | off | With `-f`: print the plan, mutate nothing |\n| `--allow-stale <a,b>` | none | With `-f`: waive named stale agents (apply-only) |\n| `--runtime <name>` | manifest's | With `-f`: override the manifest's runtime |\n| `--expect-owner <u_\u2026>` | none | With `COTAL_MANAGED_HANDOFF_FILE` only, and required there: the owner the handoff must carry |\n| `--expect-lifecycle-uid <uid>` | none | With `COTAL_MANAGED_HANDOFF_FILE` only, and required there: the lifecycle UID the handoff must carry |\n\nEach session uses its connector's **event plane** by default: a stream of structured events\ndescribing what the agent did, rather than the prose it wrote, on a channel of its own. The channel is named after\nthe agent's principal, `events.<owner>.<actor>`, never after its display name, because two live\nagents are allowed to share a display name and would then share a stream. The launch grants publish\nrights on that channel alone, foreground and detached alike. On an open mesh, which issues no\ncredentials, the launch still allocates the agent an id, so the channel names a stable actor.\n`--no-events` is the explicit opt-out unless the selected registration says\n`policy: { events: \"required\" }`. Required policy makes the\nevent arm and grant mandatory, so `--no-events` and connectors without an event plane are refused.\n\nThe launch decision and the grant are separate on purpose. Holding publish rights on a channel is\nnot a request to publish to it, so writing an event channel into an agent file's `allowPublish`\ndoes not override `--no-events`.\n\nThe persona (`--config` > positional > `COTAL_DEFAULT_PERSONA` > `default`) is loaded from the\ntarget mesh's `.cotal/agents/` when it is a bare name. A reference that contains a path separator or\nends in `.md` is loaded from that file. A relative path resolves against the mesh root, except that\nan enrollment or a managed handoff resolves `--config` against the working directory. A missing\npersona is refused with the catalog directory or the file that was checked. The launch flags\noverride the file. On a user-auth mesh the\neffective name is also the agent's actor token, so it must match the token grammar (no `-`); the\nspawn is refused with that explanation before any request is sent. Foreground runs the agent\nattached to your terminal; `--detach` hands the launch to the running manager. Both modes get the\ndurable backstop on a mesh that runs the delivery daemon; `--live-only` skips it for a foreground\nspawn (messages posted while it is disconnected are then not replayed). A foreground exit retires\nthe agent's creds and broker footprint, like a manager despawn. The creds and the footprint are\nremoved as separate steps, so a creds file that cannot be deleted does not leave the broker durables\nbehind, and the failed steps are reported together. On a user-auth mesh the two arms\ndiffer: a spawn against a mesh this machine provisioned revokes the actor row on exit, while a\nremote spawn (an enrollment or the advertised provisioning endpoint) removes only this machine's\ncredential files; its grant stays until the mesh operator revokes it, and the launch line says\nwhich arm you are on. A spawn through the advertised provisioning endpoint against a record that\npins no exchange URL is refused before the grant is requested, so no credential lands on this\nmachine. A foreground `--share-tools` selection that names a server the config does not declare is\nrefused before anything is provisioned. A `--detach` spawn is an\n**action**: the manager accepts it and returns the allocated identity at once, then the launch\nfollows to a terminal outcome rather than blocking (see [the control surface](control-surface.md)).\nSee [Connect Claude Code](connect-claude.md) and [Agent files](agent-files.md); `-f` is a\n[manifest deploy](#manifest-deploys). (`cotal start` was merged into `cotal spawn --detach`.)\nA `--detach` spawn onto a manager from another Cotal release is refused before any request is sent\nwhen the manager's contract does not declare a field this CLI sends. The refusal names the field,\ncalls it version skew, and gives this CLI's version. A field you leave unset is not sent, so it\nnever causes that refusal.\n\nA manager has 50 seat slots, and each seat counts once. A slot is held by a managed seat (a row in\nthat manager's `cotal ps`, including a seat still joining), by a reserved launch the manager accepted\nbut has not started a process for, or by a cooling hold. A seat that ends within 10 seconds of\nstarting leaves its slot cooling until those 10 seconds pass, unless an operator stopped it. Such a\nseat holds only that cooling slot, even while its launch is still reporting the failure. A spawn\nrefused at the limit states that split and whether waiting can free a slot:\n\n```text\nat capacity (50 of 50 slots: 49 managed, 0 reserved, 1 cooling); waiting frees a cooling slot in 7s, or despawn one\n```\n\nA cooling slot frees at the stated time. A launch that has not settled frees its slot only if it\nfails, and a managed seat frees its slot only when it stops. The refusal counts a launch as pending\nonly while it holds a slot, so a launch whose seat already ended is not counted. The roster counts\npresence, which also includes peers no manager owns, so its total is a different number.\n\nRun from a managed seat's own shell on a static or open mesh, `cotal spawn --detach` launches as\nthat seat when it targets the seat's own space. Without `--space` it picks that target the way the\noperator path does, so a recorded mesh that is not running is skipped. The CLI reads the seat's\nlaunch identity (`COTAL_NAME`, `COTAL_ID`, `COTAL_LIFECYCLE_UID`, `COTAL_SPACE`, and on a static\nmesh the seat's own credential), so the manager records the seat as the spawner, the same as for\nthe seat's `cotal_spawn` tool. On a static mesh that credential also proves the seat's space, so a\nlaunch without `COTAL_SPACE` still runs as the seat, and a target space holding no credential for\nthe seat is refused. An open mesh acts as the seat only when `COTAL_SPACE` names its space. The\nseat can then stop the child with `cotal_despawn`, and the manager stops the child when the seat\nexits. On a static mesh a seat whose agent file lacks `capabilities: [spawn]` is refused, because\nits credential holds no spawn subject.\n`--on <instance>` keeps its pin: the seat's own credential has no instance route, so on a static\nmesh the CLI mints a one-shot `manager-caller` view for the seat, pinned to that instance and\ncarrying the spawn subject only when the seat's credential holds it. On an open mesh the call keeps\nthe TLS requirement the mesh records. `--creds`, `--server` with an unregistered `--space`, and a\nuser-auth mesh keep the operator path.\n\nForeground user-mode launches serialize their local actor material. A duplicate refuses\nwithout replacing the first launch's files. A new local-user foreground launch also refuses\nan existing grant before rotating it. Departure cleanup is conditional on the launch's token\nand lifecycle generation, so an earlier launch cannot revoke a successor's grant. Remote\nprovisioning remains the mesh operator's authority and can reissue material before the\nclient observes an existing same-host consumer.\n\n## models\n\n```bash\ncotal models [--agent <connector>] [--refresh]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which manager to reach |\n| `--agent <connector>` | all registered connectors | Connector whose catalog to list |\n| `--refresh` | off | Ask the connector to refresh its provider cache |\n\nAsks the running manager for each connector's model catalog (model ids plus their variants)\nfor connectors that expose one. OpenCode and Codex query harness/provider surfaces; Jcode reads\nproviders that enable `model_catalog = true` in the operator Jcode `config.toml`. Jcode's listed\neffort tiers render as `variants (declared, not provider-verified)`, and launch can still refuse one.\nA connector without a catalog says so. A connector whose harness the manager did not find at boot\nreports the reason boot recorded, as a spawn does, so restart the manager after installing it. Pick\na result with `cotal spawn --model <id> --variant <v>`, where `<id>` is the model id as the catalog\nprinted it. OpenCode and Codex ids are the full\n`provider/model`; Jcode ids are bare (`opus-5`, not `cliproxy/opus-5`), because the provider is\nselected by the operator's Jcode config and a prefixed id is refused at launch with the bare form\nnamed.\n\n## endpoints\n\n```bash\ncotal endpoints [--space <s>] [--server <url>] [--creds <path>]\n```\n\nLists the mesh presence roster: agents, the manager, and any other protocol endpoint, with each\nendpoint's role, kind, status, and current activity. Unlike `ps`, this is a read-only presence view;\nit is not limited to child processes owned by the manager.\n\n## Endpoint control\n\n```bash\ncotal describe <endpoint> [--on <instance>] [--space <s>]\ncotal invoke <endpoint> <command> [--args '<json>'] [--space <s>]\ncotal invoke <endpoint> <command> --name <agent> [--admin] [--space <s>]\n```\n\nThe generic v0.4 service surface. `describe` resolves a registered endpoint's command set off the\nwire - the reserved `describe` command answers the registered contract digests, the schemas are\nfetched from the space's content-addressed contract store, recompiled, and verified against those\ndigests - and prints each command with its capability class and targeting shape. `--on <instance>`\npins `describe` to one manager instance's rail (the whole id, as `ps` prints it under its\n`manager <id>` headers), so an operator can read what that instance serves in a multi-manager space;\nunpinned, the class queue answers and the attribution line names whichever instance did. `invoke`\ncalls one command by name: `--args` is a JSON object validated against the fetched input schema\n*before*\npublish; a targeted command takes `--name <agent>` (resolved to the agent's current principal through\n`inspect`) or `--self`. `--admin` uses the admin instrument credential, whose cross-agent reach rides\nthe operator-only `any` authorization mode. Neither command has compile-time knowledge of any\nendpoint's schemas - this is the same trust chain every built-in control command now uses. Needs an\nauth mesh: the manager registers its service on both static and per-user meshes (a signed-in user\nrides their bearer; each visible or invoked command still requires its existing grant, and cross-agent\nreach needs the `admin` scope). An open mesh has no service registry.\n\n## Managed seats\n\n```bash\ncotal ps [--on <instance>] [--wide | --json] [--slots] [--space <s>]\ncotal stop --name <n> [--on <instance>] [--space <s>]\ncotal attach --name <n> [--on <instance>] [--no-reconnect] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which manager to reach |\n| `--name <n>` | none | Managed agent to stop / attach (required) |\n| `--on <instance>` | class anycast (`ps`: class scatter) | Pin to one manager instance id (multi-manager space); takes the whole id as `ps` prints it, not a prefix. An empty value (`--on \"\"`, an unset shell variable) is refused, never treated as absent. A roster principal id (`local.\u2026`) is refused with a message naming the instance id `ps` prints |\n| `--wide` (`ps`) | off | After each seat's compact row, print extra operational facts the manager records: the provider the connector reported serving the model, `cwd`, `pid`, spawner, lifecycle uid, the owning manager's instance id and host, and for a `--resume` seat the session it forked (`forked from <id>`, with the source title and transcript SHA-256 once a Hermes or Jcode seat has recorded its fork; a carried Claude session prints `forked from <host>:<id>` with its title, SHA-256 and `carried <time>`, the time its bytes reached the manager). Model and requested variant stay in the identity row rather than printing twice. A fact the manager did not record (for example a runtime with no real process, or a connector that reported no provider) prints nothing, never a placeholder |\n| `--json` (`ps`) | off | Machine-readable: one JSON object per seat per line, copied unchanged from the manager row. Instance headers and errors go to stderr, so stdout contains only rows. Mutually exclusive with `--wide` |\n| `--slots` (`ps`) | off | List the durable static slot rows this manager owns instead of live seats, through the `slots` command. Mutually exclusive with `--wide`. A row that is not in the live roster still prints, with `live=false`; a retired row never prints |\n| `--no-reconnect` (`attach`) | off | End the attach when its session ends, instead of re-establishing it. For scripts that want one run and one exit code |\n\nA raw `--creds` file is refused by `ps`, `stop`, `attach` and the other control commands, because\nthat route mints no endpoint-caller triple; the project folder, or `--space` against the registry\nentry, is the route that does.\n\nThe human `ps` row is presentation text and is not a stable parsing target. Scripts use `--json`,\nwhich is the machine-readable row contract.\n\n`--slots --wide` is refused: `--slots` lists durable static slot rows, `--wide` prints live seat facts, and the two answer different questions. Across a multi-manager scatter, `--slots` prints each manager's rows under its own instance header, the same way the plain `ps` scatter does.\n\nThese are operator clients over the running manager's control plane. The default row includes the\nconnector, model pin, optional requested variant, and runtime as operational descriptors for the\nmanaged row. They do not make a shared display name a unique protocol identity; use `--json` when\nunambiguous owner+actor attribution is required. An omitted variant means no override was requested;\nCotal does not invent an effective provider default it cannot observe. `ps` also prints two state\nfacts per managed agent, because they answer different questions: the process fact from the manager's\nown runtime handle (`running` with its uptime, or `exited` with how long it ran), and the mesh fact\nfrom the roster (`idle` / `working` / `waiting` / `mesh offline`, or `not in roster` when the seat has\nno presence row at all: a seat that has not joined yet, or one that never did). When the seat's\nconnector relays a harness-reported condition, the mesh fact carries its code and how long it has\nheld, so a seat whose turn died on a provider rate limit reads `waiting (rate_limit for 40m)` rather\nthan a bare `waiting`, and `--json` carries the whole `condition` object. When the connector reports\nthe seat's last work event (presence `activeAt`), the mesh fact ends with its age, such as\n`\xB7 active 3s ago`, and `--json` carries `activeAt`. A seat whose turn stopped advancing keeps\nheartbeating, so its presence row stays fresh and this age is what shows the stall. A seat can be\n`running` and `mesh offline` at once: the process is alive and its presence has lapsed. That row says\nhow long, as in `mesh offline for <age>` with an age such as `3.5h`, counted from the seat's last\npresence heartbeat, which `--json` carries as `offlineSince` (epoch ms). The age is read only from\nthe seat's own presence record, matched on its principal and lifecycle uid, so a same-named peer or\nan older lifecycle never dates it. The manager log names each managed seat that is offline on the\nmesh while its slot is held\n(`seat offline on the mesh: <name> - last heartbeat <time>; process <state>`), including one its\nwatch first sees offline after a reconnect, and each one that comes back\n(`seat back on the mesh: <name>`), so a watchdog that only checks process liveness has a line to\nact on. The manager does not reap or re-key such a seat. The mesh fact is only a verdict while the\nmanager's own presence watch is fresh: when that watch has been silent past the liveness window, or\nhas not replayed the bucket yet, every row prints `mesh unknown` with the reason instead (`--json`\ncarries it as `meshView: stale | unpopulated`), because `offline` and `not in roster` would then\ndescribe the manager's watch rather than the seat. The manager rebinds a watch that goes\nsilent under a live connection on its own, so `mesh unknown` normally clears within a liveness window.\nOn a user-auth mesh `ps` also renders each managed agent's last credential-refresh outcome, fail-closed.\n\n**Mode split (chosen up front, never try-scatter-then-degrade):**\n\n- **Static / open mesh.** Bare `ps` is a **class scatter**: it freezes the live manager class from\n the records registry, merges every registered instance's agents grouped and attributed per\n instance, and a non-answering instance is shown as `registered, no answer within the deadline`\n (never silently omitted). A refused list or a missing answer makes the census incomplete: rows\n from other instances remain visible, but `ps` prints an incomplete-census warning on stderr and\n exits non-zero, including with `--json`. Those rows are not a complete seat count. A contract\n mismatch prints one plain comparison of the requested and served input/output digest pairs and\n advises aligning manager versions. The no-answer label means only that the instance is registered\n and did not answer. It does not say the host is down, because a dead host never deregisters itself and a\n live one can be slow; if it is gone, deregister it.\n `--on <instance>` pins the read to one exact instance id instead. A wrong pin fails loud\n rather than falling through: a well-formed id that no live manager carries is reported as\n `manager instance <id> did not answer` (nothing else is asked), and a credential without that\n instance's rail is reported as refused by the broker, not as an unresponsive manager. A manager\n that answers with a refusal is shown with its own cause; \"no manager reachable\" is said only when\n nothing answered at all. If the scatter's own registry read fails (the freeze or the reconcile),\n `ps` says the manager registry could not be read rather than pronouncing on the managers, which\n may all be up.\n\n**The verdict is scoped to the endpoint rail the request rode.** An issued caller rides the\nversioned `ep.v1` rail, a separate subject space from the legacy `ep` rail, and an endpoint serves\nboth (SPEC 13.15). A manager older than the versioned rail serves `ep` alone, so it can be running,\nregistered and answering while an issued caller's request reaches nobody. Silence on `ep.v1` is\nreported as `no manager answered on the <rail> rail` with `ep.v1` as the rail, and names both causes\nit is consistent with: no manager running, or one older than the rail. The CLI cannot tell them\napart, because the service registry records no package version, so check whether a manager is\nrunning and, if it is, its version. The same scoping applies to `cotal run`'s hosted verbs, which\ndrop the `--local` suggestion there, since `--local` drives the run from the calling process and\nnames the caller as its answerer.\n\n**`stop` and `attach` route by seat locality.** A seat can only be stopped or attached by the\nmanager actually running it, and the class queue does not know which one that is. So on a\nstatic/open mesh both verbs first ask every registered instance which one hosts the named seat, then\naddress that instance directly. This happens by default; you do not need `--on`.\n\n`--on <instance>` remains the override, for when you already know where the seat lives or the\nlookup itself is degraded. On a **user-auth mesh**, the exchange selects one authorized manager\nfor a short-lived `manager-caller` view. `--on` requests a specific instance; without it, selection\nmust be unique. Discovery and the command use that instance route. The caller gains no registry\nread or scatter permission. An absent, ambiguous or unauthorized selection refuses before sending\nthe command.\n\nA seat is reported as **not found** only when every reachable instance answered for itself. An\ninstance that stayed silent past the deadline, or that refused the read rather than answering, said\nnothing about which seats it hosts, so the seat may be running on it. That case reports that the\nlocation could not be established, names the instances that did not answer, and states outright\nthat it is not a report that the seat is gone. Read it as unknown and retry with\n`--on <instance>`; a retry loop that treats it as \"already gone\" stops looking for a seat that is\nstill running. A single manager cannot tell \"hosted elsewhere\" from \"does not exist\": it answers\n`not-found` for both, which is why the search asks all of them and why an incomplete search\nconcludes nothing.\n- **User-auth mesh.** `cotal ps` reports what **one** authorized manager knows about your agents\n (an instance-addressed read against its in-memory roster, owner-filtered). It does **not** report\n other manager instances or establish whether they are reachable. Completeness across a\n multi-manager user-auth space is not claimed.\n A manager that does not answer fails the command outright (exit non-zero), rather than printing\n an empty list that could be read as \"no agents\". Your ledger row needs the `admin` scope to\n reach `ps` at all; `spawn` alone is refused by the broker (the ep tier boundary).\n\n`attach` streams and drives an agent's terminal on the `pty` runtime; detach with the escape key\n(Ctrl-] by default; see [`COTAL_DETACH_KEY`](config.md)). The key is recognised as the legacy\ncontrol byte and as the kitty keyboard protocol and xterm modifyOtherKeys encodings of the same\npress, so a terminal with either protocol enabled detaches too. It does so over a one-use, holder-bound\nmesh session ([SPEC](../SPEC.md) \xA713.6): the manager replies with a signed session grant (never a\n`127.0.0.1` URL), the CLI redeems it once over the broker, and the browser console (`cotal console`)\ndrives the same session. `stop` and `attach` need a running manager to talk to. On a static mesh\nthey are cross-agent admin operations. On a user-auth mesh, your own agents (any agent under your\nowner) need only the `spawn` scope; another owner's agent needs `admin` on your ledger row\n([identity & auth](identity-and-auth.md)). Launch detached agents with [`spawn --detach`](#spawn).\n\n**`attach` reconnects when the link dies.** A session lives on a network link, and a laptop that\nsleeps, a VPN that drops or a wifi handover kills it. When that happens `attach` prints\n`[cotal: connection lost, reconnecting]` on stderr and starts asking the manager for a new session:\na fresh grant, a fresh per-session credential, a fresh connection, so every attempt re-runs the same\nauthorization the first attach did. On success it prints `[cotal: reconnected]`, the manager repaints\nthe seat's current screen the way it does for any attach, and you carry on in the same terminal.\nRetries wait 1s, 2s, 5s, 10s, then 30s, for as long as the seat exists. The detach key is read the\nwhole time the loop runs, the waits and the attempts alike, so a reconnect never traps you: press it\nwhile a session is being established and the attach ends there, and a session that lands behind the\npress is handed back to the manager rather than left holding a slot. Everything else you type while\nthere is no session is dropped rather than queued, so keystrokes aimed at a terminal that turned out\nto be frozen, Ctrl-C included, are not delivered to the agent by a reconnect you did not know had\nhappened. That starts before the first session, not at the first reconnect: at a terminal, `attach`\nreads and drops what you type while it is still resolving the mesh, so a key struck at a prompt that\nhas not come up yet does not reach the agent when it does.\nThe terminal is in raw mode for the whole reconnect, including when the link died before the first\nsession finished opening, so the detach key works there too instead of echoing as `^]`.\n\nA **pipe** carries script input. For example, `printf 'ls\\n' | cotal attach --name web` is\nbuffered until the session opens. Buffering continues across reconnects, so\n`tail -f log | cotal attach --name web` does not lose the part of its feed written while the link was\ndown. Only a terminal gets the reader; `--no-reconnect` keeps the old behaviour on both.\n\nIt stops on its own when reconnecting cannot help, and says why: a manager that refuses the attach\nexits non-zero with the manager's own message, and a reconnect that finds the seat no longer there\n(despawned, or its agent exited while the link was down) exits cleanly with `seat <name> is gone`.\nA local connect refusal that retrying cannot fix, such as a static-auth mesh whose seed is now\nmissing, also exits non-zero with the refusal's own sentence. A broker that is still unreachable\nkeeps the loop trying in silence.\nA refusal that could still pass, such as a manager at its session ceiling, is relayed in the\nmanager's own words while the loop keeps trying, once per refusal rather than once per attempt.\nPressing the detach key, or the agent's process exiting while you are attached, ends the attach as\nit always did. `--no-reconnect` turns all of this off and restores the single-session behaviour,\nwhich is what a script wants.\n\nEach reconnect also hands the abandoned session back to the manager, over the first link that can\ncarry the message, so an attach that flaps does not eat the manager's session slots one outage at a\ntime. If that message never gets a link, the attach says so when it ends. The live-session ceiling\ndefaults to 64 concurrent sessions (`--max-sessions`); the browser console opens one session per\npane, so a dashboard over a large mesh should size for agents \xD7 panes. Hitting the ceiling refuses\nbefore a credential is minted and names `--max-sessions`.\n\nWhich mesh `attach` resolves also decides **how it redeems the grant**. On a registered open mesh\nthere is no local seed. The CLI connects bare, the same way other control commands already do, and\nthe session rail is the caller rail that a real open-mode connection already reaches. Telling the\noperator to re-register the root is false: the registered root is already the contract. On a\nstatic-auth mesh the grant is still redeemed by minting a short-lived\nsession-scoped credential from the seed at the root the mesh resolved to, never from a `.cotal`\nfound by walking up from whichever directory you happen to be standing in. The difference is not\nhypothetical: `~/.cotal` exists on every install because the mesh registry lives there, so a command\nrun anywhere under your home directory but outside a project used to mint from your home\ndirectory's trust and present it to a broker that trusts a different chain, which surfaced as a\nbare authorization failure that named nothing. A directory that does hold another chain for the\nsame space is now reported on the way past, and not obeyed:\n\n```text\n! this directory resolves to /Users/you, whose .cotal/auth holds a DIFFERENT trust chain for space \"team\".\n attach used /Users/you/projects/app, the root this mesh resolved to. The other one is not being used, and is worth a look.\n```\n\nWhen a **static-auth** mesh holds no seed at the resolved root, `attach` refuses and names what it\nresolved, the broker and the root, instead of describing a directory it did not use and instead of\ntaking the open-mode path. An authenticated registry entry with a missing seed is still\nauthenticated. On a USER-AUTH mesh `attach` reads no seed. It sends your login and the session grant to\nthe auth service, which issues a `session-caller` bearer only if your owner and actor hold that\nsession. The connection it opens expires with the session grant.\n\nTerminal bytes stream over the mesh; the manager's own HTTP/WS face serves the console. That endpoint binds\n**loopback by default**, so nothing is exposed by accident; `cotal up --host <addr>` passes its bind\naddress down, which is what lets you reach the browser console (`cotal console`) for an agent whose manager runs on another machine.\n`attach` does not use that face: it redeems a signed mesh session grant over the broker instead (see above), so it reaches a\nremote manager regardless of the bind address. A\nbare `cotal supervise` and an embedded manager stay machine-local. Set it directly with\n`supervise --console-host <host>`.\n\nThat address is **recorded on the mesh** and carried forward, because it is a decision rather than\nsomething later commands can work out for themselves (a broker dial address is not a manager bind\naddress). Every later manager launch for the same mesh reuses it, including a same-root `cotal up` repair,\nan adopted preserved or restored listener, and a `spawn -f` manifest deploy. A manager replacement\ndoes not quietly move a reachable attach face back to loopback. Passing `--host` again overrides it,\nso you can widen or narrow exposure whenever you like; a mesh that never asked stays loopback-only\nand records nothing.\n\nBecause that face mints terminal read and write authority for every managed agent's browser session, it is credentialed in two\ntiers. A mesh caller receives a **ticket** bound to the single agent the manager just authorized,\nsingle-use and short-lived, so one authorized attach can never be re-pointed at someone else's\nagent. The **console token** is the operator's own, reaches every agent, and is printed only to the\nmanager's output. The roster, the live feed, and the PTY stream all answer `401` without one; the\nstatic console shell is served openly, since it describes no agent.\n\n## input\n\n```bash\ncotal input --name <n> --text <text> [--no-enter] [--on <instance>] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which manager to reach |\n| `--name <n>` | | Managed agent to type into (required) |\n| `--text <text>` | | The text to type, taken verbatim (required) |\n| `--no-enter` | off | Type the text and stop there, without pressing Enter |\n| `--on <instance>` | class anycast | Pin to one manager instance id using the same rules as [`attach`](#managed-seats) |\n\nTypes one line into a running agent's terminal, as if you had typed it there, and returns. This is\nthe half of [`attach`](#managed-seats) that a program wants: `attach` is a live stream that holds a\nsession open and expects a terminal on your side, so a script, a cron job or a web UI cannot use it\nto send a single line. `input` is one authorized call.\n\nWhat it is for is **harness commands**. A line beginning with `/` is not chat and not a message: it\nis something the agent's own harness handles, and the only way in is the keyboard.\n\n```bash\ncotal input --name reviewer --text \"/compact\" # ask the harness to compact its context\ncotal input --name reviewer --text \"/model opus\" # switch its model\ncotal input --name reviewer --text \"hold on that PR\" # ordinary typing works too\n```\n\n**Quoting.** `--text` takes a value, so a payload starting with `/` survives as written. A payload\nstarting with a dash needs the `=` form, because the shell-style `--text --foo` is ambiguous and is\nrefused rather than guessed:\n\n```bash\ncotal input --name reviewer --text=--verbose # dash-leading text: use --text=<value>\n```\n\nEnter is pressed by default, since a command typed but never submitted has not been delivered.\n`--no-enter` types the text and leaves it sitting at the prompt, which is how you stage a line and\nsend it later.\n\nNothing comes back but a delivery receipt (`\u2713 sent 9 bytes to reviewer`, counting the trailing\ncarriage return). Whatever the agent does next shows up where its output already goes: the mesh, its\ntranscript, or an `attach`.\n\n**This one is operator-only, and more narrowly than `stop` or `attach`.** Those two are granted to\nanything holding `spawn`, so an agent can stop and attach to seats under its own owner. `input` is\nnot: it is granted only to operator credentials, which on a user-auth mesh means your ledger row\nneeds the `admin` scope, the same scope [`ps`](#managed-seats) already needs there. The reason is\nthat a write into a terminal is control of whatever is running in it, and on a user-auth mesh the\nown-owner rule covers every seat under you, not only the ones you launched: a `spawn`-scoped agent\ncould otherwise type into a sibling it never started. Seat locality is still resolved for you.\n\nOnly the `pty` runtime can be typed into. The external terminal runtimes (`tmux`, `cmux`, `orca`,\n`herdr`) attach to a process they do not own, so they have no input stream for it and the command\nrefuses by name rather than dropping the keystroke.\n\n## personas\n\n```bash\ncotal personas list [-v] [--running]\ncotal personas show <name>\ncotal personas edit <name>\ncotal personas new <name> (--prompt <t> | --from <f>) [--role <r>] [--model <m>]\ncotal personas rm <name> --force\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which mesh's persona catalog |\n| `--role <r>` | none | `new`: the persona's role |\n| `--model <m>` | none | `new`: the persona's model |\n| `--prompt <t>` | none | `new`: the persona's prompt text |\n| `--from <f>` | none | `new`: seed the prompt from a file |\n| `--verbose`, `-v` | off | `list`: include role / model / description |\n| `--running` | off | `list`: mark personas live on the mesh |\n| `--force` | none | `rm`: required, delete without prompting |\n\nPersonas are the local agent files under the resolved mesh root's `.cotal/agents/`, the same catalog\n`cotal spawn` launches from. `--space` and `--server` therefore move every list, read, write, delete\nand completion operation to the selected mesh. An unresolved target refuses rather than falling back\nto the current directory. See [Agent files](agent-files.md) for the file format.\n\n## supervise\n\n```bash\ncotal supervise [--runtime <name>] [--space <s>] [--server <url>] [--spawn <names>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | this folder's auth space | Space to supervise |\n| `--server <url>` | hosting mesh, or matching registered mesh | Broker URL. A registered mesh supplies it when omitted; a different explicit value is refused before anything is dialed. |\n| `--runtime <name>` | `pty` | Agent runtime (`pty` built in; extension runtimes are explicit-only) |\n| `--console-port <n>` | none | Protocol-console port |\n| `--console-host <host>` | loopback | Bind host for the console endpoint. Loopback keeps it machine-local; `cotal up` passes the address it bound the broker to, which is what lets the browser console reach this manager from another machine. `cotal attach` does not use this face: it redeems a mesh session grant over the broker |\n| `--max-sessions <n>` | 64 | Live-session ceiling. Each console pane and each `cotal attach` is one session, so size for agents \xD7 panes, not agent count. A capacity refusal names this flag. `cotal up --max-sessions` records the same number on the mesh so a later `supervise` started by repair or `spawn -f` keeps it |\n| `--roster <file>` | none | Declarative roster to boot at startup. See [Roster files](define-a-team.md#roster-files) |\n| `--launch <spec>` | none | Resolved manifest launch spec (from `up -f` / `spawn -f`) |\n| `--spawn <names>` | none | Comma-separated personas to pre-spawn at startup |\n\nThe manager is the agent supervisor and control plane: it answers `spawn --detach`, `stop`, `ps`,\n`attach`, and the `cotal_*` manager tools. `cotal up --detach` starts one for you; run `supervise`\ndirectly to recover a dead manager or drive a custom runtime. Default runtime is `pty`; install an\noptional provider first (`cotal ext add @cotal-ai/orca`, `@cotal-ai/tmux`, `@cotal-ai/cmux`, or `@cotal-ai/herdr`) and\nselect it explicitly. A missing provider or app fails loudly; there is no fallback. See [Deploy](deploy.md).\nBoot inventory decides whether this process takes unpinned `spawn`/`launch` on the class rail:\nif every declared connector is unavailable, those commands stay on this instance rail only\n(`status` reports `classSpawn: false`). `describe` still answers on the class rail, so an\nunpinned spawn can bind-fence against a skip member; re-issue, or pin `--on`. A partial\ninventory keeps the class rail and names `--on` on a harness refusal, because sibling\ninventories are not readable from the serve credential. See [control surface](control-surface.md#instance-routing).\n\nOn a normal `SIGINT`/`SIGTERM`, the manager stops every seat and requires the selected runtime to\nprove the seat is gone before it releases the manager lease or service registration. A stop that\ncannot prove exit fails loud and keeps manager authority instead of reporting a clean shutdown while\nan orphan still holds broker rails. When the runtime refuses the stop itself, the shutdown fails at\nonce and its error names the refusal as `stop failed: <message>`. After an abrupt manager death, the same logical successor\nterminalizes only its own durable static slots, verify-evicts the predecessor's broker principal,\nrecords that result in the lifecycle's caller-readable audit detail, reaps the predecessor's seat\nprocess through the runtime's custody reference recorded on the slot (the pty runtime verifies the\nprocess start identity in its seat record, so a reused pid is never signalled), and only then\nretires the lifecycle and frees the alias. A runtime that custodies its seats reserves that\nreference before it launches one, and the manager records it on the slot's first durable row, so a\nmanager that dies part-way through a spawn also leaves a seat its successor can address. A\nsame-lifecycle restart or a resume records the new seat's reference on the slot the same way, and\nwhen the slot does not take it the restart or resume fails and stops any seat it started, so the\nslot never names a seat that has already exited while its replacement runs. A resumed seat keeps\nits retained credentials, so the resume frees it only once its exit is proved; a seat whose stop\ncannot be proved stays managed, and the resume's error says so, naming a stop the runtime refused\nas `stop failed: <message>`. A spawn\nthat launched its seat and then failed is rolled back by the manager that launched it, and that\nrollback reaps the seat through the same reserved reference before the lifecycle retires. Missing or unverified broker evidence keeps the slot\nterminalizing, and so does a runtime that cannot reap by reference. While the name stays held, a\nsame-name spawn or a resume re-drives that teardown with the same recorded reference, so it reaps\nthe seat again or keeps the name held.\n\nA `meshes add --mode user` entry is a **participant** registration, not hosting authority. A\nparticipant may run `supervise` only when the host advertises the remote manager authority service\nand the signed-in actor has the dedicated `supervise` ledger scope. The CLI obtains the closed,\nloopback-only `manager-service` view; `spawn` and `admin` do not substitute for that scope. The\nhost issues the manager's public-nkey JWT material through its lifecycle-bound prepare \u2192 activate\n\u2192 renew protocol, never by handing the participant a signer or static provisioner credential.\nThe host also performs instance-scoped eviction and guarded gate reconciliation. A remote manager\nrefreshes its short-lived registration executor before clean deregistration, so a long-running\nprocess removes its service row on `SIGINT` or `SIGTERM`. After an unclean stop, the same instance\nverify-evicts its superseded family and advances the process epoch. If an abandoned frozen gate\nholds the manager governance slot, a different supervise-scoped manager asks the host to reconcile\nthat holder after a complete gone verdict, then retries its registration once.\n\nA remote supervise never uses local signing trust: with host-issued authority in hand, the\nmanager mints from that authority alone and consults local records only to refuse a conflict,\nnamely the supervised space's own trust records under the cwd root. A root that hosts another\nstatic space beside the sign-in is a normal configuration and is never read as this space's\ntrust.\n\nThe broker URL in the registry entry decides the transport. A remote broker is often published\nover a `wss://` edge rather than a raw `nats://` port, and `supervise` dials whichever scheme the\nrecord holds, starting with the manager-authority registration it runs before the manager exists.\nThe record also decides whether that registration requires TLS, so a participant never downgrades\nthe credential exchange to a plaintext connection the registry did not describe.\n\nWithout that advertised host service or scope, `supervise` refuses before it starts a manager.\nRun `cotal spawn` without `--detach` to launch a foreground agent, or ask the space host to enable\nthe authority service and grant `supervise` for detached agents. If a running remote manager loses\nrenewal, it reports degraded state and refuses unsafe new starts and restarts; live agents are not\nsilently replaced. Do not run `cotal down` or `cotal up` on a participant machine to repair this\ncondition.\n\n## service\n\n```bash\ncotal service install [--mesh <name>] [--linger]\ncotal service status [--mesh <name>] [--json]\ncotal service uninstall [--mesh <name>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--mesh <name>` | this folder's mesh | The mesh whose manager the service runs; one unit per mesh |\n| `--linger` | off | install: when lingering is off, ask logind to enable it so the user manager starts at boot and the service survives logout. Never enabled silently |\n| `--json` | off | status: machine-readable output |\n\nRuns the manager as a user service so it survives logout and reboot. On Linux this installs a\nsystemd user unit (`~/.config/systemd/user/cotal-manager@<key>.service`, where `<key>` is the\ncase-safe mesh key); on macOS a launchd agent plist under `~/Library/LaunchAgents/`. Any other\nplatform, or an absent systemd/launchd user session, fails with a message naming what is missing.\n\n`install` resolves the mesh from the registry and binds the unit to that entry's root and broker\naddress, so it can be run from any directory. The mesh must be registered (`cotal up` or\n`cotal meshes add`) before installing; an unregistered name refuses before anything is written.\n\nThe unit's `ExecStart` is the bare `supervise` command. The mesh facts travel in the unit's\nenvironment (`COTAL_SPACE`, `COTAL_SERVER` pinned to the registered broker URL, whatever port it\nlistens on) rather than the command line, because command lines are readable by every user on a\nmulti-user host. On Linux that environment is a `0600` `EnvironmentFile`; on macOS it is the\nplist's `EnvironmentVariables`. The same environment gives the service a private `COTAL_HOME` and\n`XDG_CONFIG_HOME` under the unit directory, so the service manager never touches the login\nuser's `~/.cotal`. First-run connector seeding runs synchronously inside `service install`,\nagainst that private config root; the unit itself starts with `COTAL_SKIP_CONNECTOR_SEED=1`\nso a manager is never interrupted mid-seed by a restart. An install whose pre-seed cannot\ncomplete (network unreachable, registry error) refuses instead of deferring.\n\nThe same environment pins `PATH` to the `PATH` of the shell that ran `install`.\nWithout it the unit inherits the service manager's own short\n`PATH`, which usually lacks `~/.local/bin` and Homebrew, so the manager's boot inventory would\nreport a harness unavailable that your shell resolves. Install from a shell that resolves every\nharness the service should launch, and reinstall after moving one. A relative entry, including\nan empty one, is resolved against the directory you ran `install` from, because the unit starts in\nthe mesh root where the same spelling names another directory. An entry with a `..` segment is\npinned as the directory your shell reaches through it, with symlinks followed, and refuses when it\nreaches none. A `PATH` set to the empty string is one empty entry, so it pins that directory. An\nunset `PATH` refuses.\n\nEvery value the unit derives from a path (`WorkingDirectory`, the `EnvironmentFile` path, the\n`ExecStart` tokens) is escaped for systemd specifiers (`%` becomes `%%`), so a mesh root that\ncontains `%` starts over its real path instead of a path systemd rewrote by expanding it. The\nprovenance comment records the root unescaped.\n\nOn Linux a user unit starts at boot and survives logout only while the user lingers. Without\nlingering, systemd starts no user manager at boot, so an enabled unit stays inert until the next\nlogin and stops at the last logout. `install` checks lingering before it writes anything, and when\nlingering is off it fails with the root command that turns it on (`sudo loginctl enable-linger\n<user>`). With `--linger` it first asks logind to enable lingering for the current user, and fails\nwith the same command when logind refuses (unprivileged users over SSH get `Access denied`).\n`service status` prints that command while lingering is off. A Linger query that does not answer\n`yes` or `no` (logind unreachable, no `loginctl`) is never read as off: `install` refuses with\nthe query's own error and enables nothing, and `service status` shows lingering as unknown with\nthat error (`--json` gives `\"linger\": { \"error\": ... }`).\n\n`service install` also refuses while a manager is already running for the mesh (`cotal down\nmanager` first). The restart policy is `Restart=always` with `RestartSec=20s`, chosen for\nmanager units in production: a manager exits for reasons that are not failures (broker\nrestarts, host suspend), where `on-failure` with a short interval thrashes.\n\nThe unit also sets a start limit (`StartLimitIntervalSec=30min`, `StartLimitBurst=20`). A manager\nthat keeps failing to start stops after 20 attempts, about seven minutes at 20 seconds apart, and\nthe unit is left `failed` instead of restarting forever. One such failure is deliberate. After an\nunclean stop, a manager that cannot verify eviction of its predecessor's credentials exits 1 and\nleaves the issuance gate frozen, because starting without that proof could let two incarnations\nserve at once (SPEC 13.1). It first waits up to 60 seconds for the delivery daemon to answer, so a\ndaemon that is still starting does not fail the start. The log names the cause. When the delivery\ndaemon is down, it says the daemon is not reachable on the `ctl.delivery-admin` rail. When the\ndaemon answers and refuses, for example because the space is missing a `$SYS` cred, it prints the\ndaemon's own reason and repair step. Fix that cause, then run `systemctl --user reset-failed\n<unit>` and `systemctl --user start <unit>`. The macOS agent has no start limit: launchd's\n`ThrottleInterval` only spaces restarts.\n\n`service status` reports the unit state from systemd/launchd, the manager's own health read from\nits pidfile at the unit's recorded root, and the machine facts a hosting side asks for:\narchitecture, OS (the platform, never the hostname), whether `/dev/kvm` is present and\naccessible, CPU count, and total memory. `--json` returns the same fields as one object. The\nmanager row names the recorded pid, and the command it runs when another program has reused that\npid. `--json` also gives the command of a live recorded pid whenever it can be read.\n\n`service uninstall` stops and disables the unit and removes it plus the private state directory.\nIt works from any directory: the unit's own records name the mesh and root it serves, and an\nexplicit `--mesh <name>` selects it. It refuses any unit that was not written by `service\ninstall` (the files carry a provenance comment), whose recorded mesh is missing, or that was\ninstalled for a different mesh, so operator-written units are never destroyed; `service status`\napplies the same rule and never reports a mesh a unit does not record. `service status` also\nrefuses a unit that records no absolute root, because the manager's health is read at that root\nand is never guessed from the current directory. Remove such a unit with `service uninstall`\nand install it again.\n\nThis command installs only the manager. The per-space auth service and the delivery daemon are\nnot installed by it: on a shared broker an operator runs three units per space with `After=`\nedges (auth service, then manager, then delivery) and stops them in reverse. A broker-side `cotal\nup` unit is a separate unit documented in [Run a mesh](run-a-mesh.md).\n\n## reconcile-gate\n\n```bash\ncotal reconcile-gate [--space <s>] [--server <url>] [--endpoint <e>] [--instance <id>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | this folder's auth space | Space the frozen gate lives in |\n| `--server <url>` | the local mesh | Broker URL |\n| `--endpoint <e>` | `manager` | Endpoint whose gate is frozen |\n| `--instance <id>` | this folder's persisted manager instance | Instance id |\n\n**When you need this.** A manager restart killed after deregistration begins but before the new\nincarnation finishes leaves the endpoint's issuance gate *frozen*, held by a\nprocess that no longer exists. The freeze is what stops two incarnations serving at once, which is\ncorrect. The successor manager now completes that dead registration itself on boot, including on\nthe remote user-auth path. A foreign remote manager blocked by this gate also asks the host to repair\nit before one registration retry. Both use the same guard this command uses: they act only when the freeze-holder is affirmatively gone under a complete\nCONNZ sweep (`gone` and `sweepComplete=true`). If that registration's spec write already committed,\nit finishes the same freeze at the committed registration revision. If the spec did not advance, it\nabort-reopens the gate at generation+1 with processEpoch unchanged and continues the normal takeover.\nLive, unknown, unestablishable, and\nwrong-op-kind still refuse; there is no TTL.\n\nUse this command when the automatic path cannot run: the delivery daemon is down, the repair targets a\nnon-manager endpoint, or you want to lift the freeze without starting a manager. It checks that the\nholder really is gone, prints what it found, and then finishes the dead operation the same way as the\ninterrupted restart would have: revoke the old credentials, evict their holders with verification,\nand reopen the gate.\n\nThe command revokes the old credentials 16 at a time. It then verifies the holders' eviction in\nshared sweeps of up to 256 holders on the delivery daemon. Each sweep scans the broker a fixed number\nof times and kicks live connections 16 at a time, so holders that are already gone add almost\nnothing and live ones add one broker round trip per 16 connections. The daemon must serve the\n`evictPrincipals` verb; an older daemon refuses it and the gate stays frozen.\n\nEach sweep durably records the holders it verified before the next sweep starts. If a holder is not\nverified gone, the command leaves the gate frozen with those records kept. An interrupted sweep\nrecords nothing, and the sweeps before it stay recorded. A retry still repeats the freeze-holder\nliveness check, then skips only progress bound to the same registration operation, frozen-gate\nrevision, and holder set.\nThe output reports holders completed before this attempt, completed now, and still remaining. A new\nfreeze or changed holder set starts from zero. Cursor cleanup happens only after reopen; a retained\ncursor is harmless because its old gate revision cannot authorize a later freeze. The governance slot\nrelease after the reopen is cleanup in the same way: a slot it fails to release does not fail the\nrepair, because the reopened gate is already past the slot's generation.\n\n**It refuses far more often than it acts, on purpose**, and always says which check stopped it:\n\n| Refusal | What it means | What to do |\n|---|---|---|\n| `holder-alive` | The freeze-holder still has a live connection: a manager *is* running | Stop that process first. Reconciling would evict a live manager's credentials |\n| `holder-unknown` | The connection sweep could not prove the holder absent | Not safe to proceed: an unprovable holder is treated as a live one. Re-run once the broker answers completely |\n| `liveness-unestablishable` | The delivery daemon gave no verdict: it was unreachable, timed out, or refused | Act on the delivery lease line in the refusal (below). Silence is never read as death |\n| `not-frozen` / `no-gate` | The gate is open, or there is no gate at that coordinate | Nothing to repair: check `--endpoint` / `--instance` |\n| `wrong-op-kind` | Frozen under a takeover or retirement, not a registration | Out of scope for this command; it will not reinterpret another operation's intent |\n| `eviction-unverified` | The holder looked gone but eviction could not be verified | The gate is left frozen, unchanged. Investigate the broker before retrying |\n| `raced` | A newer manager moved the gate mid-repair | Re-run `cotal doctor` and look again |\n\nWhen the daemon gives no verdict, the refusal also reads the delivery lease (`lease.0`) and names\nwhat is blocking the rail:\n\n| Lease reading | What to do |\n|---|---|\n| absent | No daemon is running. Start it (`cotal up` runs it) and re-run |\n| unreadable | The daemon cannot be named, so do not assume none is running. Fix the lease read, then re-run |\n| held, not ready | That holder claimed the shard and has not bound its rails. Wait for it, or stop it so its lease lapses |\n| held, ready, no answer | The query may have gone to another daemon still subscribed to the rail, such as a stopped one whose lease lapsed. Re-run before stopping anything. If no run gets an answer, stop any other delivery daemon for the space, then stop or restart the holder |\n| changed hands | The holder took the shard after the query was sent, so it was never asked. Re-run before stopping anything |\n\nThe command reads the lease before it sends the query and again after the query fails. It names a\nholder as the blocker only when the same run of the same daemon held the lease both times, and two\nrows from a daemon too old to record its run never count as the same run. Even then a ready holder\nmay not have been asked: the rail is queue-grouped, so any daemon still subscribed to it can take\nthe query. A row whose times are not valid dates reads as unreadable.\n\nA daemon that answered and refused keeps its own reason, followed by the same lease line. The lease\nline names the holder, whether it is ready, the space account that holds the lease bucket, when that\nholder acquired the shard, and when the row was last written. A ready holder rewrites the row on\nevery renewal and keeps its acquisition time, which only a successful acquisition sets. A row\nwritten by a daemon that predates the acquisition time reports it as unknown. The lease reads never\nchange the outcome: the gate stays frozen and the command exits 2. A manager's boot self-heal uses\nthe same check and reports the same line.\n\nThere is no `--force`, and no path that discards gate state: the only way this reopens a gate is by\nproving the holder is gone and then completing the operation properly.\n\n**What reopening the gate does for the endpoint's governance slot.** A registration takes the\nendpoint-wide governance slot before it publishes its spec, and holds it until its gate reopens. An\ninstance that died between those two points leaves the slot held with no registration behind it.\nThis command does not write that slot and never has; the registration path is its only writer. What\nthe reopen does is advance the holder's gate past the generation the slot is stamped with, which is\nwhat marks the slot abandoned. The next registration for that endpoint then reclaims it as part of\nits ordinary start. So the repair here is still one command followed by starting the manager, and\nthe slot needs no separate step.\n\n## deregister-instance\n\n```bash\ncotal deregister-instance [--space <s>] [--server <url>] [--endpoint <e>] [--instance <id>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | this folder's auth space | Space the instance is registered in |\n| `--server <url>` | the local mesh | Broker URL |\n| `--endpoint <e>` | `manager` | Endpoint the instance serves |\n| `--instance <id>` | this folder's persisted manager instance | Instance id, the whole id as `cotal ps` prints it |\n\n**When you need this.** The service registry records *registration*, not liveness, and nothing in\nthe model expires a row. A manager that stops cleanly removes its own registration. One whose host\ndied without writing anything cannot, so its record goes on claiming a live instance forever: every\nclass scatter in that space freezes the dead slot in, and `cotal ps`, `stop` and `attach` each pay\ntheir whole deadline waiting for a machine that is never coming back. A laptop that was reimaged, a\ncontainer that was deleted, a box that will not be back on the network: those registrations have no\nother exit.\n\nThis command is that exit. It asks the instance first, and it removes a record only when the broker\naffirms the instance's own rail is empty: nothing subscribed there. Then it deletes the\nregistration's two records keys, each pinned to the revision it read, and prints what it removed.\n\n**Silence alone never passes.** An unanswered describe is what a dead host, a wedged process and a\nslow one all look like, and a hung process still holds its subscriptions, so the broker sees\ninterest on its rail. That instance is refused and the observation is printed. A dead process holds\nno connection and therefore no subscription, so a real corpse is still removed.\n\n**Every refusal names the failed check:**\n\n| Refusal | What it means | What to do |\n|---|---|---|\n| `instance-answered` | The instance answered a pinned describe. It is alive | Nothing to repair. If it is wedged rather than gone, stop the process first; its own clean stop removes the record |\n| `instance-not-affirmed-gone` | It did not answer, and the broker did not report its rail empty, which is what a held subscription looks like: slow or hung, not affirmed gone | Nothing was removed. Stop the process; its record goes on its own clean stop, or re-run this once it is down |\n| `liveness-unestablishable` | The probe itself failed, so nothing was learned | Fix the probe's path (credential, broker) and re-run. A probe that could not run is never read as death |\n| `not-registered` | No registration at that coordinate | Check `--instance` and `--endpoint`. This takes the whole id, never a prefix |\n| `registration-in-flight` | The instance holds the endpoint governance slot at the live issuance-gate generation, so a registration is still completing | Nothing was removed. Wait for that registration to finish, then re-run |\n| `superseded` | The record moved between the read and the delete | Something is writing to it. The registration was not removed, and a status that was removed observed the inspected registration or an earlier one. Re-observe before retrying |\n\n**A held slot with an unreadable issuance gate is not one of those refusals.** When the instance\nstill holds the endpoint governance slot, the command reads its issuance-gate generation to tell a\ncompleting registration from a leftover. If that gate is absent or carries a delete marker, the\nread fails and the command refuses the delete rather than guess. The caller receives an\n`unavailable` error, not a named refusal: the message begins `could not observe the issuance-gate\ngeneration for \"<endpoint>/<instance>\" while its governance slot is held` and ends with the reader's\nown explanation, either `no endpoint gate at <key>` or `the endpoint gate <key> carries a\n<operation> marker; a gate is never deleted (corruption, not absence, SPEC 13.12)`, where\n`<operation>` is the single token `DEL` or `PURGE`. The two cases are told apart by that text\nalone; the error class and the exit are the same.\nNothing was removed. A gate is never deleted in normal operation, so treat either text as a damaged\nissuance gate for that instance and repair it before re-running. A manager's own clean stop hits the\nsame read; it logs the failure and finishes stopping, and its registration stays in place.\n\nThere is no `--force` and no sweep: silence is not death, and a rule that removed rows on silence\nwould eventually remove a live instance that was merely slow. An operator names one instance, the\nbroker's verdict on its rail is what authorizes the removal, and the guard's job is to show them\nthey named a dead one. Removal is not a one way door either. The same instance re-registers over\nthe tombstone on its next start, under the same identity.\n\n## runtimes\n\n```bash\ncotal runtimes\n```\n\nLists every agent runtime the manager can spawn through: the built-in `pty`, the official providers\n(`orca`, `tmux`, `cmux`, `herdr`), and any custom provider installed via `cotal ext add`. Each installed\nprovider is probed so you can see what is actually reachable on this machine before selecting it:\n\n```\npty built in\norca installed \xB7 reachable @cotal-ai/orca\ntmux available \xB7 cotal ext add @cotal-ai/tmux\ncmux available \xB7 cotal ext add @cotal-ai/cmux\nherdr available \xB7 cotal ext add @cotal-ai/herdr\n```\n\n`installed \xB7 reachable` / `unreachable` is the provider's own `available()` probe; `available` means\nit is a known runtime you can add with the shown command. Selecting an unknown or uninstalled runtime\nvia `up`/`spawn --runtime <name>` fails loud and, for a known one, points at the exact `cotal ext add`\npackage. There is no silent fallback to `pty`.\n\n## seats\n\n```bash\ncotal seats [--drain]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--drain` | off | Retire every seat whose agent has exited. A seat whose agent still runs is kept |\n\nThe pty runtime used to start a detached custodian process for every Linux seat. It now spawns\nin-process, but custodians that an earlier manager started keep running, and one whose agent has\nexited stays resident while a manager still holds its connection. This command lists the custody\nrecords under `COTAL_SEAT_ROOT` (default `~/.cotal/seats`), one line per seat:\n\n| State | Meaning |\n|---|---|\n| `live-child` | The agent process still runs. The seat is never signalled, and a manager can still adopt it |\n| `childless` | The agent has exited, or the record comes from an earlier boot. `--drain` retires the seat |\n| `drained` | `--drain` proved the custodian and the agent gone and removed the record |\n| `refused` | The record cannot be read, carries no start or boot identity, this host publishes no boot identity, or the reap could not prove the processes gone. The record stays on disk |\n\nA drain signals only a custodian whose recorded start identity still matches the live process, so\na reused pid is never touched. No process outlives a reboot, so a record from an earlier boot is\nreported childless and `--drain` removes it without signalling anything. A record with no start or\nboot identity is refused with or without `--drain`, and is never reported as running or exited.\nOn a host that publishes no boot identity (`/proc/sys/kernel/random/boot_id`) every record is\nrefused the same way, because no record can be tied to this boot.\nThat refusal and an unreadable record signal nothing. A refusal from the reap itself can come after the drain already\nsent `SIGKILL` to the custodian. Its detail names the pid or process group the reap could not prove\ngone, so check those processes before you retry. The command exits non-zero when any record is\nrefused. It is Linux-only and throws on other platforms.\n\n## send\n\n```bash\ncotal send dm <agent> \"<text>\" [--space <s>] [--server <url>] [--creds <path>]\ncotal send msg <channel> \"<text>\"\ncotal send ask <role> \"<text>\"\n```\n\nA `send dm` prints one line naming three facts: `\u2192 <name> stored seq <N>; recipient <status>\nat send; delivery not confirmed <text>`. `stored seq N` is the JetStream sequence the broker\nassigned to the publish; `recipient <status> at send` is the roster status (`idle`, `working`,\nor `offline`) resolved right before the publish, which can change the instant after; the send\nnever prints `delivered`, because the sender's credential cannot read the recipient's durable\nto confirm it. Inspect what the broker actually holds for a recipient with\n[`cotal deliver pending`](#deliver).\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which mesh, and (off-registry) which credential |\n\nOne-shot messaging: connect, send a single direct message (`dm`), channel post (`msg`), or role\nask/anycast (`ask`), then exit. For a running conversation, agents use the mesh tools instead\n([MCP tools](mcp-tools.md)).\n\n`cotal send` works from an operator shell or from a seat. Its display name is `<login>@<host>` of\nthe shell that ran it, so the recipient can tell one operator's send from another's; it is taken\nfrom the operating system, never from `COTAL_NAME`. The wire principal comes from the resolved\noperator credential or user bearer, not from `COTAL_NAME`, `COTAL_ID`, `COTAL_OWNER`, or\n`COTAL_ACTOR`. On an open mesh the transient endpoint self-mints its principal.\n\nThe transient endpoint never joins the roster and binds no inbox. A recipient can still answer a\n`send dm` or `send ask` with `cotal_dm`, by the sender's name or by the id on the message it\nholds: the reply is stored under the sender's id in the space's DM history, which an operator's DM\nview such as the dashboard's Direct messages lens shows. The `cotal send` that asked has already\nexited, so the reply never reaches that shell.\n\n## channels\n\n```bash\ncotal channels list\ncotal channels set <name> [--replay | --no-replay] [--window <n>] [--desc <s>] [--instructions <s>]\ncotal channels default --replay | --no-replay\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Target mesh |\n| `--replay` / `--no-replay` | none | `set`/`default`: replay history to new joiners, or not |\n| `--window <n>` | none | `set`: replay window size |\n| `--desc <s>` | none | `set`: one-line channel description |\n| `--instructions <s>` | none | `set`: instructions shown to joiners |\n\nInspects and edits the channel registry: replay policy, description, and joiner instructions. ACL\nsemantics (who may read or post) are set at mint / provision time, not here; see\n[Channels and permissions](channels-and-permissions.md). On a user-auth mesh, `list` rides your\nown login as is; `set` and `default` edit the registry over a short-lived\nchannel-writer view, which needs ledger scope `admin` ([Identity & auth](identity-and-auth.md)).\nOn a remote user-auth mesh that view is served by the public exchange; space-history `purger`\nand the read-only admin view are not.\n\n\n## history\n\n```bash\ncotal history clear --force [--dms] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Target mesh |\n| `--dms` | off | Also clear DM history |\n| `--force` | none | Required: clear without prompting |\n\nPurges retained channel history; `--dms` extends it to direct-message history. An alias of\n[`clean history`](#clean). On a user-auth mesh the purge rides a short-lived purger view over\nyour login, which needs ledger scope `admin` ([Identity & auth](identity-and-auth.md)).\n\n## console\n\n```bash\ncotal console [--plain] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Space to watch |\n| `--plain` | off | Line stream instead of the TUI |\n\nA live protocol view for a space: a lazygit-style TUI, or a plain line stream on `--plain`. On a\nuser-auth mesh it rides the read-only admin view over your login, which needs ledger scope\n`admin`. Inside the TUI, operator control (`D` kill, `:spawn`, `:status`, `:purge`) rides the\nsame per-action instrument path as `cotal stop` and `cotal ps`, never the observer; a raw\n`--creds` file cannot drive it. `a` (or `:attach <agent>`) runs\n[`cotal attach`](#managed-seats) in place and returns to the console on detach. See\n[Watch a mesh](watch-a-mesh.md).\n\n## web\n\n```bash\ncotal web [--detach] [--host <host>] [--port <n>] [--no-open] [--space <s>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Space to serve |\n| `--host <host>` | `127.0.0.1` | Concrete HTTP bind and browser host; wildcard addresses are refused |\n| `--port <n>` | `7799` | HTTP port, a decimal number from 1 to 65535 |\n| `--detach` | off | Run in the background; stop with `cotal down web` or bare `cotal down` |\n| `--no-open` | off | Don't open the browser |\n\nThe browser observability dashboard: presence, channels, and a live feed. It is **not** part of\n`cotal up`: it ships inside `cotal-ai` as the `@cotal-ai/web` extension, seeded automatically on first\nrun (like the built-in connectors) so it always matches your CLI version. It self-registers `cotal web`\ninto this surface and serves\n`http://cotal.localhost:7799` by default (loopback; `*.localhost` resolves in Chrome/Firefox/Edge; for Safari\nor a system resolver such as WSL2's, the launch link is also printed at `http://127.0.0.1:7799`).\nOn a user-auth mesh the dashboard rides the read-only admin view\nover your login, and a channel purge asks for its own channel-purger view per click; both need\nledger scope `admin`. The public exchange serves `channel-purger` for a remote owner; it still\nrefuses the startup admin view, so a remote `cotal web` is not a complete channel-management\nsurface. Detached mode re-execs the current Cotal installation, writes diagnostics to\nthe mesh root's `.cotal/web.log`, and reports success only after the HTTP server answers. It requires\na recorded mesh root, but can be launched from any directory once `cotal up` has recorded the mesh.\nSee [Watch a mesh](watch-a-mesh.md).\n\n## linear\n\n```bash\ncotal ext add @cotal-ai/linear\ncotal linear account add <name> --mode <write|readonly> (--token-stdin | --token-file <path>)\ncotal linear account login <name> --mode <write|readonly>\ncotal linear account show <name>\ncotal linear inventory <account> [--json]\ncotal linear call <account> <tool> [--args '<json>'] [--inventory <digest>] [--timeout <ms>]\ncotal linear resource <account> <uri> [--inventory <digest>] [--timeout <ms>]\ncotal linear prompt <account> <name> [--args '<json>'] [--inventory <digest>] [--timeout <ms>]\ncotal linear serve <account> --endpoint <reverse-dns-name> [--space <s>] [--server <url>]\ncotal linear caller <name> --endpoint <reverse-dns-name> --out <path> [--channels <a,b>] [--expires-in <s>]\n```\n\nServes one Linear account as a registered Cotal endpoint that agents call with `cotal_describe` and\n`cotal_invoke`, and gives the operator a direct client for the same server. It only talks to `https://mcp.linear.app/mcp`\n(mode `write`) or `https://mcp.linear.app/mcp/readonly` (mode `readonly`); there is no URL or header\noption. Use a separate account per Linear workspace, and prefer a `readonly` account with a\nrestricted read key wherever writes are not needed.\n\nAn account's credential is sent as `Authorization: Bearer` and is never taken from argv. `account add`\ntakes a Linear API key: `--token-stdin` stores it under the cotal home as a 0600 file, and\n`--token-file` points at an existing file that must not be readable by other users. The file is read\nat each use, so rotating it needs no restart. `account login` runs Linear's OAuth flow instead: it\nregisters a client, prints the authorize URL, and waits on a loopback redirect. It asks for scope\n`read` in `readonly` mode and `read write` in `write` mode, keeps the tokens in a 0600 file, and\nrefreshes them before a request is sent. OAuth requests only go to `https://mcp.linear.app`.\nRedirects are refused so the credential is never forwarded.\n\n`inventory` reads the server's capabilities and every page of its tools, plus resources, resource\ntemplates and prompts when the server advertises them. Names and schemas are printed as the server\nsent them. The digest covers the whole inventory; pass it to `--inventory` so a call is refused\nbefore dispatch if the inventory changed. Capabilities this command does not represent, such as\nresource subscriptions or logging, are listed under `unsupported`.\n\n`call`, `resource` and `prompt` print the server's reply as JSON. A tool result with `isError: true`\nis a normal result (exit 0). A refusal before dispatch exits 2 with outcome `not-executed`; that\nincludes a deadline that expires while queued, discovering or connecting. A protocol error, a\ntimeout, Ctrl-C, a response over the size cap, an expired session, an HTTP 401, 403 or 429 answer,\nor a transport failure after the request was sent exits 3 and is never retried. Its outcome is\n`unknown`, for read-only tools too: read-only means a repeat is safe, not that the first call did\nnot run. Errors carry an HTTP status or an error class, never the response body.\n\n### Serving the endpoint\n\n`serve` registers the account as an endpoint named by `--endpoint` and serves it until Ctrl-C. The\nname must be a reverse-DNS name in a namespace you own, such as `com.example.linear`; it is\nauthorized for this mesh's local owner only because you configured it. `serve` reads the inventory\nfirst, so an account that cannot be reached is never registered. It then walks the same path every\nregistered endpoint does: it publishes the contract to the contract store, opens the issuance gate,\nregisters a fresh instance, writes its ready status, and mints a scoped serve credential through\nthe gate. Every step runs on short-lived executor credentials scoped to this one endpoint instance.\nThe space signer stays in the `serve` process for renewal at 75% of the credential's life and is\nnever written out, printed, or handed to the serving connection. Ctrl-C stops serving, closes the\nLinear session and removes the instance's service record, each within 10 seconds. A start that\nfails after registration removes the record before it exits and says whether that removal\ncompleted; a registration that fails part-way rolls its own record back and reports when it could not.\n\nThe endpoint serves five fixed commands: `inventory`, `call-tool`, `read-resource`, `get-prompt` and\n`complete`. Each needs the one `linear.mcp` capability. That capability is permission to call the\nendpoint; which tools a call can reach is decided by the Linear account and its mode. `inventory`\nreplies in pages that fit a broker message: the first page carries the server, its capabilities,\nits instructions and per-section counts, and every page carries entries verbatim with an opaque\ncursor bound to the inventory digest. An entry is never cut: discovery refuses an inventory whose\nfirst-page head (server, instructions, capabilities) or any entry prints over 48 KiB the way an\nagent tool prints it (indented JSON, which grows with nesting), naming the entry, so the first page\nand every entry it accepts fit one reply and the tool an agent reads it with. A cursor or `inventoryDigest` from an older inventory is refused before anything is sent to\nLinear. `call-tool`, `read-resource`, `get-prompt` and `complete` require the digest the caller read.\n\n### Callers\n\n`caller` provisions an isolated caller on a static-auth mesh: an `agent` credential with its own\nmailboxes and request rows for only the five Linear commands on that endpoint, plus the channels\nin `--channels`. It carries no spawn, run, admin or provisioner capability. The credential is\nwritten 0600 to `--out` and never printed. Start a hand-driven connector session with it, using the\nprinted `COTAL_CREDS` and `COTAL_LIFECYCLE_UID`; that session then sees the endpoint through\n`cotal_describe` and calls it through `cotal_invoke`, and the broker refuses any command its\ncredential does not name. A Claude Code or jcode session also serves a local control endpoint and\nrefuses to start without one: set `COTAL_CONTROL_SOCKET` to a socket path you choose and\n`COTAL_CONTROL_TOKEN` to a random secret. OpenCode, Codex and pi sessions start without them. Managed `cotal spawn` agents on a static-auth mesh still cannot carry\nendpoint capabilities, so a Linear caller is a hand-launched seat.\n\nA per-user-auth mesh is refused by both `serve` and `caller`: there, endpoint and caller credentials\ncome from the remote auth service, which this package does not support yet. Nothing falls back to a\nlocal signer. An open mesh is refused too, because it has no credentials to scope.\n\nNot represented yet, and listed under `unsupported` when the server advertises them: resource\nsubscriptions, logging, progress notifications and experimental capabilities. The server cannot\ncall back for sampling, elicitation or roots. Every call acts as the account's one Linear identity.\n\n## deliver\n\n```bash\ncotal deliver [--space <s>] [--server <url>] [--tls] [--creds <file>] [--root <dir>] [--shard <n>] [--shards <n>] [--dev-mint]\ncotal deliver pending <name> [--limit <n>] [--durable <name>] [--json]\n```\n\nWith no positional, `cotal deliver` runs the delivery daemon (see\n[the delivery daemon](delivery-daemon.md)). `deliver pending <name>` never starts the daemon: it\nis an operator-only read over one recipient's DM durable, for the moment after a send when the\nquestion is \"what does the broker actually hold for them.\" It resolves `<name>` against a short\npresence watch (an `offline` card still counts, since the recipient may be dead, that is what\nthe verb exists to inspect); when neither a card nor the durable can be found, it prints\n`\u2717 not-found: no agent \"<name>\" and no DM durable for it in space <s>` and exits non-zero, never\n`pending 0`. On a match it prints the durable name and one fact per line: `pending`,\n`ack-pending`, `delivered`, `ack-floor`, `created`, `frontier`, and the stream's `max_age` /\n`max_msgs_per_subject` / `discard` limits (`--json` prints the same facts as one object), followed\nby a bounded, unacked read of up to `--limit` (default 20) recent candidate message ids under the\nheading `recent candidate ids (from the ack floor; not proof of a hole)`, a list of what is\nthere, not proof that nothing was lost.\n\nThe verb needs the `admin` credential profile: it runs through the same static-mesh route as\n`cotal mint --profile admin`, and refuses a user-mode mesh, naming the retired static credential,\nbecause there is no user-mode inspection authority yet. Pass `--creds <file>` for an off-registry\nadmin credential. A same-name respawn never inherits a predecessor's held DMs (the durable is\nlifecycle-keyed); an old lifecycle's durable is reachable only by the name a live read printed\n(the `<durable>` line on the first line of this verb's output). Pass that name with `--durable\n<name>` to read it directly once the lifecycle's card is gone from the roster. This skips the\npresence watch on `<name>` entirely, so `<name>` is required but only echoed in error text.\n\n## mint\n\n```bash\ncotal mint <name> [--profile <agent|observer|admin>] [--out <path>] [--signer]\ncotal mint <name> --provision [--role <role>] [--space <s>] [--server <url>]\ncotal mint <name> --expires-in <seconds> | --expires-at <unix-seconds>\ncotal mint <name> --identity <creds> [--expires-in <seconds>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--profile <agent\\|observer\\|admin>` | `agent` | Credential profile |\n| `--out <path>` | `.cotal/auth/creds/space.<key>/<name>.creds` | Output path - the default sits under the resolved space's segment (`<key>` is that space's hex encoding, as in [Project files](config.md#project-files)) |\n| `--signer` | off | Emit a stripped account-signing file instead |\n| `--force` | off | With `--signer`: overwrite an existing file |\n| `--allow-subscribe <a,b>` | the agent file's, else subscribe | Read-ACL override, **agent profile only**: `observer` and `admin` carry a fixed read set, and `mint` refuses this flag there rather than narrowing nothing |\n| `--allow-publish <a,b>` | the agent file's, else deny | Post-ACL override, **agent profile only** |\n| `--role <role>` | the agent file's | Agent profile: the anycast task queue the identity pulls (`svc_<role>`) |\n| `--provision` | off | Agent profile: also pre-create the identity's bind-only DM/deliver durables (and its role's task queue) on the live mesh, so the credential can consume |\n| `--expires-in <seconds>` | unbounded | Bound the credential's lifetime: the JWT `exp` is `iat + <seconds>`. A positive integer; refused together with `--expires-at` |\n| `--expires-at <unix-seconds>` | unbounded | Bound the credential to an absolute `exp` (unix seconds). Refused together with `--expires-in` |\n| `--identity <creds>` | a fresh identity | Re-mint for the nkey carried by this creds file, keeping the principal and every durable keyed to it. The file is read by the same loader the endpoint uses; a file with no seed is refused by name |\n| `--space <s>`, `--server <url>` | the resolved mesh | Which root supplies the agent file, static trust and default credential storage; with `--provision`, also which live mesh receives the durables |\n\nMints a NATS creds file for a space in **static** auth mode, scoped to a profile and (optionally)\nexplicit read/post ACLs. `--signer` emits an account-signing file for delegating minting to another\nhost. On POSIX the file `mint` writes is mode `0600`, including when it replaces an existing file at\n`--out`. A per-user-auth space refuses `mint`: agents there join under a logged-in user\n([`login`](#login) + [`actor grant`](#actor)), never via a handed-out creds file. See\n[Identity and auth](identity-and-auth.md).\n\nFor an agent profile, the resolved mesh root supplies the persona ACL, the signing material and the\ndefault credential destination as one authority. If the current folder also holds trust for a\ndifferent space or account, mint refuses before writing and names both roots. It never combines a\npersona from one root with credentials signed or stored under another.\n\nA plain mint is creds only: the identity can publish within its post ACL at once, but on an authed\nmesh its DM inbox and task queue are provisioner-pre-created and bind-only, so a **consuming**\nconnect fails until they exist. `--provision` performs that pre-create in the same command (a\nprovisioner cred is minted from the space's trust material, used, and dropped), so a long-running\nclient you start yourself can receive DMs and role anycasts like a spawned seat. The command prints\nthe identity's principal (its wire id) and lifecycle uid; a consuming client passes that uid as its\n`lifecycleUid`. Agent profile only; an open mesh needs none of this (peers self-create there). The\nsame resolved authority is used for both the credential and `--provision`, so the broker\nfootprint cannot be created under a different root's trust material.\n\nThe CLI-mintable profiles carry no default TTL: without a lifetime flag the credential is\nunbounded, and a standing-renewal consumer refuses it. `--expires-in <seconds>` (or\n`--expires-at`) is the door the renewal seam's own error names. `--identity <creds>` re-mints for\nthe nkey the file already carries, so the new credential presents the SAME principal and every\ndurable keyed to it survives; combine it with a lifetime flag to rotate an expiring credential\nwithout churning the identity.\n\n## Login\n\n```bash\ncotal login --idp <auth base URL> [--client-id <id>]\ncotal logout --idp <auth base URL>\n```\n\nSigns you in to a per-user-auth mesh's IdP (device code flow) and caches the session; run it\nonce per machine. The IdP URL must use `https://`. Plain `http://` is accepted only on a loopback IP\nliteral such as `127.0.0.1` or `::1`, for an IdP on this machine. `localhost` is refused because it\nis a name: a hosts entry would choose the IdP. It prints your IdP subject, the id the operator\ngrants against. When the trusted\n`/token` response advertises a same-origin space catalog, login validates and records that account's\nspaces immediately. After a\nlogin, every command on that mesh works under your identity: each connect takes a fresh IdP\nproof, exchanges it locally for a short-lived bearer, and is authorized against the actor\nledger at connect time. `logout` revokes the IdP session, clears its cache, and removes only that\naccount's discovered registry entries. See\n[identity & auth](identity-and-auth.md).\n\n## actor\n\n```bash\n# an upsert of the WHOLE row: name all three ACL flags, or pass --full for the wide defaults below\ncotal actor grant <actor> --sub <IdP subject> --scope a,b --allow-subscribe a,b --allow-publish a,b [--role <r>] [--label <l>]\ncotal actor grant <actor> --sub <IdP subject> --full [--scope a,b] [--allow-subscribe a,b] [--allow-publish a,b] [--role <r>] [--label <l>]\ncotal actor revoke <actor> (--sub <IdP subject> | --owner <u_\u2026>)\ncotal actor list\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` | the folder's | Space whose ledger to manage |\n| `--sub <subject>` | none | The IdP subject (shown by `cotal login`) the actor belongs to |\n| `--owner <u_\u2026>` | none | The derived owner token (alternative to `--sub`) |\n| `--full` | off | Fill each ACL flag left off with its wide default; without it, `grant` refuses unless all three are named |\n| `--scope <a,b>` | `spawn,role:default` with `--full` | Capability scope (`''` = none; `spawn` = may run agents; `role:<r>` = may delegate role r; `admin` = cross-agent control; `supervise` = eligible for the closed remote manager-service view when the host enables it) |\n| `--allow-subscribe <a,b>` | `>` (all channels) with `--full` | Channel read ACL; the user's envelope, their agents can never read beyond it |\n| `--allow-publish <a,b>` | `>` (all channels) with `--full` | Channel post ACL; also the envelope for their agents' posting |\n| `--role <r>` | none | Role (scopes the task-queue consumer) |\n| `--label <l>` | none | Display label for `actor list` (never the IdP subject) |\n\nThe actor ledger is the single authorization source of a user-auth space: no row, no access.\n`grant --full` is the **full** envelope (all channels; scope `spawn,role:default`, so it may spawn and may delegate the default role). A\n`grant` that leaves off `--scope`, `--allow-subscribe` or `--allow-publish` without `--full` is\nrefused and writes nothing. A re-grant **replaces the whole row**, not the one field you name, so to add a capability spell\nevery field out: the new scope plus the row's current read set, post set, role and label\n(`cotal actor list` shows what a row holds). Under `--full`, a field left off does not stay as it\nwas: it reverts to the wide default in the table above. A re-grant retires the current interactive lifecycle through the running auth\nservice before it rotates the row, so copied bearers cannot cross an authorization update. If that\nretirement cannot be confirmed, the row is left unchanged and the command fails with the recovery\naction. `revoke` uses the same retirement before deleting the row, which lets a later grant create a\nreal successor instead of colliding with a live predecessor. `supervise` is separate from `spawn` and `admin`: it only makes a signed-in\nperson eligible for the host-provided closed remote manager-service view; it does not grant\nmanagement of another owner or a general host profile. `revoke` denies the next exchange and\nthe next connect with no restart, and evicts the principal's live connections. Managed-agent rows\n(written by the spawn path) live in a disjoint row space this command never touches. See\n[identity & auth](identity-and-auth.md).\n\n## doctor\n\n```bash\ncotal doctor auth [--fix]\n```\n\nCredential-health diagnosis and repair for this folder's mesh: renders every managed\ncredential as healthy / near-expiry / expired and ends in `healthy` or the exact next\ncommand; `--fix` applies the repairs it can. The one surface every stale-credential error\npoints at. `--fix` takes the mesh's renewal lease when the broker answers and refuses while\na manager or another doctor holds it; with no broker it repairs offline and says so.\n\n## join\n\n```bash\ncotal join --space <s> --name <n> [--role <r>] [--channel <c>]\ncotal join --link <url> | --token <t>\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Which mesh, and which credential |\n| `--name <n>` | none | Your presence name |\n| `--role <r>` | none | Your role |\n| `--channel <c>` | none | Channel to join |\n| `--kind <k>` | `agent` | Endpoint kind |\n| `--link <url>` | none | Join link (`cotal://\u2026`) |\n| `--token <t>` | none | Join token |\n| `--lifecycle-uid <uid>` | none | Required with `--creds`: the lifecycle UID minted alongside the credential (`COTAL_LIFECYCLE_UID` works too). A credential's durable grants name exact lifecycle-keyed resources, so `join` refuses to invent one |\n| `--tls` | off | Connect over TLS |\n\nAn interactive presence: join a space under your own name and role, without launching an agent\nharness. A `--link` or `--token` supplies the where and the auth in one value. See\n[Spaces](spaces.md) and [Identity and auth](identity-and-auth.md).\n\n## Manifest deploys\n\nA `cotal.yaml` manifest declares a whole mesh (channels, personas, roles, and ACLs) in one file.\nThree commands consume it, plus a read-only validator:\n\n```bash\ncotal up -f cotal.yaml # boot a fresh mesh from the manifest\ncotal spawn -f cotal.yaml # deploy the manifest additively onto a running mesh\ncotal down -f cotal.yaml # tear that deploy down (or --run <id> for one run)\ncotal topology view -f cotal.yaml # validate + view the access graph, change nothing\n```\n\n`up -f` and `spawn -f` differ in target: `up -f` brings up a new broker and applies the manifest;\n`spawn -f` requires an already-reachable mesh and applies additively (ownership-scoped). On a\nuser-auth mesh, `spawn -f` deploys over your own login (the deployer view, gated on ledger scope\n`spawn`): the manifest's agents land under your owner, a manifest claiming another owner is\nrefused, and seeding new channels additionally needs scope `admin`. Both take\n`--dry-run` to print the plan without mutating anything. `topology` validates the manifest and\nrenders its channel / role / ACL graph. See [Define a team](define-a-team.md) and the\n[manifest reference](manifest.md).\n\n## ext\n\n```bash\ncotal ext # same as `list`\ncotal ext add <npm-package>\ncotal ext remove <name>\ncotal ext list [--json]\ncotal ext root # print just the install prefix (scriptable)\ncotal ext seed [--repair|--reset|--force]\n```\n\nOperator-installed extensions: `add` installs an npm package into a cotal-owned prefix and records\nevery registry provider it contributes. Commands appear in help, completion, and dispatch; runtime\nproviders are lazy-loaded by commands such as `supervise`; local process providers participate in\n`status` and selective `down`. `remove` and `list` manage them. The `@cotal-ai/web` dashboard is the\ncanonical command/process example. Installed packages and their location are described in\n[config](config.md). When a package needs an export its linked `@cotal-ai/*` peer does not have,\n`add` rolls back and names which install is behind, as a later load of an installed one does.\n\nBare `cotal ext` lists the inventory, headed by the install prefix. That prefix is a cotal-owned npm\nroot kept **separate** from npm's own global tree. These packages never show up in `npm list -g`,\n`cotal ext` (or the Extensions section of `cotal status`) is the canonical inventory. `cotal ext root`\nprints only the path, for scripts. The versions shown are the manifest pin recorded at add time.\n\n`ext list --json` (or bare `ext --json`) prints one JSON object per installed extension per line:\n`pkg`, `version`, `spec` (what `ext add` was given), `seeded` (`true` for an entry the built-in seed\ninstalled) and `provides` (the `kind:name` refs the table shows). No header or footer reaches stdout,\nand an empty prefix prints nothing and exits 0. The table is presentation and is not a stable parsing\ntarget. The other `ext` subcommands refuse `--json`.\n\nRemoving an extension that owns a running local process is refused with the mesh root and its\n`cotal down <component>` command; stop it first so uninstalling the package never strands a process\nwhose lifecycle provider is gone.\n\n### Built-in connectors are seeded extensions\n\nThe first-party agent connectors (`claude`, `opencode`, `codex`, `hermes`, `jcode`, `pi`) are not compiled into\nthe binary. They are seeded on first run through the **same** `ext add` path a third party uses, and\nappear in `cotal ext list` like any other extension. So you can remove one you do not want\n(`cotal ext remove @cotal-ai/connector-hermes`), and a deliberately-removed connector STAYS removed\nacross upgrades. `cotal ext add <your-package>` adds a third-party connector the same way. The web\ndashboard (`@cotal-ai/web`, providing `command:web`) is the seventh built-in seeded on the same path.\n\n`cotal ext seed` is the maintenance entry for that seeding (it runs automatically on the first real\ncommand of each boot, so you rarely call it). Each seeded connector's `\u2713 added` line goes to stderr,\nso the command that triggered the seed keeps stdout to itself:\n\n| Flag | Meaning |\n|---|---|\n| (none) | Reconcile: seed any never-seeded built-in, refresh a seeded one whose version the binary bumped, leave a removed one removed. A no-op once current. |\n| `--repair` | Recover after an interrupted seed or a lost authority (rebuilds the interrupted connector; restores the removed-vs-never-seeded record from its durable backup). |\n| `--reset` | Discard the record and re-seed all seven built-ins (the six connectors plus the web dashboard). **Resurrects any you removed.** Rebuilds cleanly over corrupt seed state. |\n| `--force` | Re-seed the built-ins even when the version stamp is current or a downgrade. |\n\nWhen a newer `cotal` advances the operator-global seed store to its generation, it prints one\nmigration line naming the old and new generations, the exact CLI entry that wrote the store, the\ncommit timestamp, and `seed/stamp.json`. That writer and timestamp are kept in the stamp, so a later\nolder CLI refusal can say which executable wrote the generation it will not overwrite and when.\nLegacy generation-only stamps remain readable; their refusal simply has no writer provenance to add.\n\nAn older `cotal` refuses a seed store written by a newer version. When it can verify a sufficient\n`cotal` executable on PATH or at the installer's `~/.local/bin/cotal` location, the refusal names\nthat absolute path so a reduced service PATH does not select the older binary again. Otherwise it\nkeeps the generic newer-version instruction. `--force` rebuilds the store for the running older\nversion without discarding the ever-seeded authority. `--reset` still exists for corrupt state and\nresurrects deliberately-removed connectors.\n\nA source-checkout CLI (`pnpm cotal`, `tsx bin/cotal.ts`, `node bin/cotal.ts`, or a suite child of\nthose) refuses to write or garbage-collect that store. The refusal names the path, the generation\nit declined, and `COTAL_SKIP_CONNECTOR_SEED=1` as the way to run other commands from a checkout,\nbecause pointing `$XDG_CONFIG_HOME` at a scratch dir alone does not lift it. With the skip set, a fresh\nconfig gets no built-in connectors (`cotal ext list` shows none), so add one from the checkout with\n`cotal ext add <checkout>/extensions/connector-claude-code` against that scratch `$XDG_CONFIG_HOME`. `COTAL_HOME` does not relocate this\nstore. An entry that cannot be proven as a released install is refused the same way. Isolated\nrelease tests that must seed from a checkout-shaped `bin/` set `COTAL_ALLOW_CHECKOUT_SEED=1` after\npointing `$XDG_CONFIG_HOME` at a scratch dir; that override is documented here, not on the refusal\nline. An opt-in write still records the checkout path in `seed/stamp.json` as `writtenBy`.\n\nThe default connector for a bare `cotal spawn` (no `--agent`) is the persona's `agent:` pin if it\nhas one, else `claude`; set `COTAL_DEFAULT_AGENT` (e.g. `opencode`) to change the fallback. It is\na default, so a persona that pins its harness still wins over it. An `--agent` naming a removed\nconnector fails loud with the exact\n`cotal ext add` to restore it. Set `COTAL_SKIP_CONNECTOR_SEED=1` to turn off the automatic first-run\nseed/refresh entirely (for a controlled or offline setup that manages connectors by hand); `cotal ext\nseed` still runs on request. `cotal agent-bearer` never takes the seed at all: it is exec'd by\nspawned seats on every bearer refresh, so it neither reconciles nor is refused by the store's\ngeneration (see [Plumbing](#plumbing)).\n\n## completion\n\n```bash\ncotal completion <bash|zsh|fish|powershell> # print a stub to eval / source\ncotal completion install [shell] # install it persistently\n```\n\nPrints or installs shell completion. Completion candidates come from each command's declared flags\nand, where useful, live mesh state (spaces, personas, managed agents) resolved offline.\n\n## feedback\n\n```bash\ncotal feedback \"<summary>\" [--type <t>] [--email <e>] [--details <text>]\n```\n\n| Flag | Default | Meaning |\n|---|---|---|\n| `--type <t>` | none | `bug` \\| `idea` \\| `friction` \\| `praise` \\| `other` |\n| `--details <text>` | none | Longer free-form details |\n| `--severity <s>` | none | `low` \\| `medium` \\| `high` |\n| `--area <a>` | none | The part of Cotal this concerns |\n| `--email <e>` | git email | Contact email (required on the keyless public path) |\n| `--name <n>` | none | Your name (optional) |\n| `--url <url>` | keyed / public intake | Intake URL override |\n| `--key <k>` | `COTAL_FEEDBACK_KEY` | Feedback key |\n\nSends feedback to the Cotal developers. With a key (`--key` / `COTAL_FEEDBACK_KEY`) it routes to the\nkeyed beta intake; without one it goes to the public `cotal.ai` intake and requires a contact email\n(`--email` / `COTAL_FEEDBACK_EMAIL`, else your git email). Run a self-hosted intake with\n[`feedback-intake`](#server-daemons).\n\n## run\n\nOperate durable workflow runs (cotal-lang programs) from the terminal.\n\n```bash\ncotal run start --file <program> [--timeout <dur>] [--local]\ncotal run resume <runId> [--local --file <program>]\ncotal run ps [--endpoint <ep>] [--json]\ncotal run journal <runId> [--endpoint <ep>] [--json]\ncotal run answer <runId> <stepKey> [--value <json>] [--artifact <ref>] [--endpoint <ep>] [--local --by <who>]\ncotal run amend <runId> <stepKey> [--value <json>] [--artifact <ref>] [--endpoint <ep>] [--local --by <who>]\ncotal run migrate <runId> --local --file <program> [--endpoint <ep>]\n```\n\n`start` hands the program to the mesh's manager, which validates it, mints the run id (the record\nnever takes a caller-supplied one), drives it in its own process, and answers with the id once the\nrun is recorded; a program that does not validate is refused with every problem listed. `resume`\nasks the manager to take an existing run back and continue it from its step journal; the source is\nthe recorded program, so no `--file` is taken. Neither takes `--endpoint`: the manager records\nits runs under its own endpoint, and naming another is refused. `ps` lists the run records and\n`journal` renders one run's durable records; both only inspect. An open pause prints its question.\nA pause settled with an accepted answer prints its value as JSON plus the recorded answerer,\nartifact when present, time, and answer id, then one `amended` line per later amendment, in the\norder the store committed them, so the last is the current position.\nExpired pauses and ordinary steps print no answer line.\n`--json` on `ps` or `journal` prints each row the manager answers with (or `--local` reads) as one\nJSON object per line. A `ps` row carries `runId`, `endpoint`, `state`, `holder`, `epoch`,\n`journalHigh`, `forkedFrom`, `startedAt` and `programHash` (the values the program's `run()`\nreports; `programHash` is absent for a run with no recorded program), and `revoked` or\n`revocationUnreadable` when the marker says so. A `journal` row is an `activation` or a `step`. A\nstep row carries its `step` key, the `effect` kind and its `name`, `state`, `outcome`, the recorded\n`status` and `errorCode` once settled, and `startedAt` and `endedAt` in epoch milliseconds. An open\npause adds its `asks`, its `deadlineAt`, and for a checkpoint the `onExpiry` it was armed with; a\nsettled pause adds its `answer` and its `amendments`, as the text view prints them. A field the\njournal does not record is absent: a checkpoint opened before `onExpiry` was recorded carries none.\nThe run header and errors go to stderr, so stdout carries only rows; an unreadable revocation marker\nprints its reason there and still exits 1. The text view is presentation and is not a stable\nparsing target. `--json` on any other verb is refused.\n`answer` resolves an open\ncheckpoint through the manager, presenting as the holder that armed it; the manager records the\nanswerer from your credential, so no `--by` is taken there. A settled step refuses a second\n`answer`. `amend` records a changed position on a settled checkpoint or `ask`: it files a new\nanswer beside the accepted one, naming it, and the journal lists it under the step. The pause stays\nsettled and the run keeps the answer it acted on. A step that is still open or settled without an\nanswer refuses an amend. A spawned seat may amend only an answer recorded under its own name. `migrate` runs the migrate check of an\nedited program against a run's journal, from this terminal under a read credential (`--local`\nonly; the manager serves no run-migrate command): it prints whether the migration is admissible,\nevery orphaned step with its verdict and code, and exits 0 on admissible and non-zero on not. It\nwrites nothing: the commit that would file the migration is not reachable yet, and the report\nsays so. `--timeout` sets the default\ncheckpoint timeout for a drive (default 1h). `--local` drives in this process instead, over one\nconnection per invocation under the run's own credential minted from the project folder's trust\nmaterial, and is the path on a bare broker with no manager or for a run with no recorded program\n(`cotal run resume <runId> --local --file <program>`); `answer --local` and `amend --local` take\n`--by <who>`. On a\nuser-auth mesh the host's own manager refuses the family by name, and `--local` has no credential\nthere. A participant's manager started with `cotal supervise` hosts a logged-in user's runs through\nits issuing host: the auth callout issues the user's manager connection, and every `run` verb rides\nthe versioned rail under that issuance.\n[User-auth run start](design/user-auth-run-start.md)\nrecords the path. The guide is [workflows](workflows.md).\n\n## Server daemons\n\nTwo long-lived infra roles ship with the CLI. They are not part of everyday operation; the delivery\ndaemon comes up automatically with `cotal up --detach` in auth mode.\n\n```bash\ncotal deliver --space <s> [--server <url>] [--creds <file>] [--root <dir>]\ncotal auth-service --space <s> --server <url> [--port <n>] [--exchange-public-port <n>] [--exchange-public-url <https://\u2026>] [--exchange-trusted-proxy]\ncotal feedback-intake --keys <keys.json> [--port <n>] [--creds <file>]\n```\n\n`auth-service` runs a user-auth space's identity plane: the NATS auth callout, the\ncapability-gated local exchange and JWKS, and, when `--exchange-public-port` is set, the closed public\nexchange/discovery face forwarded by an HTTPS reverse proxy. `--exchange-public-url` is the proxy URL\nadvertised to clients; `--exchange-trusted-proxy` opts into last-hop `X-Forwarded-For` attribution.\n`cotal up --user-auth` starts and supervises the service for you, so you run it directly only to\nrecover one by hand.\n\n`deliver` runs the server-side Plane-3 delivery daemon: the durable backstop and membership/ACL\nauthority. It is auth-mode-only and single-instance (`--shard`/`--shards` accept only `N=1`);\n`--dev-mint` mints a scoped cred from the local signer for standalone dev. `--creds` can start a\ndaemon that already looks healthy, but production renewal is not that file alone: the manager and\nthe daemon must address one credential store. On a stock split host with two project roots, a\ndirect `deliver` is not an independent repair; keep the daemon under `cotal up` on the broker\nhost, or inject the same store into both processes ([embedding](embedding.md#supervisor-signing-authority)).\nTyped by hand on the workstation, `deliver` dials the broker recorded for `--space` in the mesh\nregistry (a mismatching `--server` is refused before any dial, and a record for a different\nworkspace root is refused outright); with no record for the space it falls back to the local mesh.\nThe daemon serves the workspace root that `--root <dir>` names, which must hold `.cotal/`, or else\nthe nearest `.cotal/` above its working directory. With neither, it refuses at start and names the\ndirectory it searched from, before it reads a credential or dials a broker.\nSee the [delivery daemon](delivery-daemon.md). `feedback-intake` runs a self-hosted feedback server\n(requires `--keys` and a scoped `--creds`), announcing submissions into a space channel; flags\ninclude `--host`/`--port`, `--store`, `--space`/`--channel`, `--max-bytes`, and `--rate-limit`.\n`--port` takes a decimal port from 1 to 65535 and is checked before the intake dials the broker.\n\n## Plumbing\n\n`cotal __complete <words\u2026>` is the internal entry the shell-completion stubs call to emit candidates\nfor the current command line; you never run it directly. `cotal agent-bearer` is machine-facing\nplumbing on user-auth meshes: spawned agents exec it to print a fresh short-lived bearer from their\nspawn-time secret; you never run it directly either. Its local arm uses `--dir` to discover the\ncapability-gated loopback service. A remotely enrolled, already-granted agent instead receives\n`--exchange-url <https://base>` in its launch argv: that arm sends `{owner, actor, actorToken}` to the\npinned public exchange with no local capability, follows no redirects, and refuses every non-HTTPS\nURL because the actor token is the credential in the request body. Because a seat execs it on every\nbearer refresh, it skips the connector-seed boot gate entirely: it reads one 0600 token file,\nexchanges it and prints the bearer without consulting or writing the operator-global seed store, so\na newer store generation cannot refuse a live seat's refresh. `--manager-call` asks for the\ninstance-bound `manager-caller` view; `--manager-instance <id>` selects an explicit live candidate.\nThat mode still prints only the raw token and does not update `--health-file`. A spawn runs it once as the agent auth\npreflight. When it fails there without printing a sentence of its own, the refusal names the cause: the 30 second\ntimeout, the signal that killed it, or its exit code. (`cotal start` is a removed tombstone: it\nerrors and points you to `cotal spawn --detach`.)\n"
62534
62537
  },
62535
62538
  {
62536
62539
  "slug": "config",
62537
62540
  "title": "Configuration",
62538
62541
  "kind": "Reference: describes the TypeScript reference implementation (the `cotal` CLI and connectors), not the wire contract.",
62539
62542
  "summary": "Three things configure a Cotal workstation: the config file (per-connector settings, notably which of your MCP servers get shared with spawned agents), a set of COTAL environment variables, and the\u2026",
62540
- "body": '# Configuration\n\n> **Reference**: describes the TypeScript reference implementation (the `cotal` CLI and connectors), not the wire contract. \xB7 **For:** operators \xB7 **Wire contract:** [SPEC](../SPEC.md)\n\nThree things configure a Cotal workstation: the **config file** (per-connector settings, notably\nwhich of your MCP servers get shared with spawned agents), a set of **`COTAL_*` environment\nvariables**, and the **on-disk layout** under a project\'s `.cotal/` and your machine\'s `~/.cotal`.\nNone of these are part of the wire contract; they configure the reference implementation only.\n\n## The config file\n\nThe cotal config file carries per-connector launch settings. It is layered from two locations,\nmost-specific-wins:\n\n| Layer | Path | Scope |\n|---|---|---|\n| Base | `$XDG_CONFIG_HOME/cotal/config.json` (else `~/.config/cotal/config.json`; `%APPDATA%\\Cotal\\config.json` on Windows) | Operator-level, every space |\n| Override | `<project-root>/.cotal/config.json` | Space-local |\n\nThey merge per connector and per server name: a server in the space-local file replaces the\nsame-named server in the operator-level file; connectors or servers present in only one side are\nkept. A missing file is empty (valid); malformed JSON, a non-object top level, or a shared server\nthat cannot launch as written is a loud error.\n\nIt carries three things: which of your personal MCP servers a connector should **share** with the\nagents it spawns, optional `spawn.env` names that deliberately add environment capability to a\nspawned agent (see [Environment variables](#environment-variables) below), and an optional\n`modelPolicy` that limits which models a role may launch on (see [Model policy](#model-policy)).\n\nThe sharing half: the Claude connector launches with `--strict-mcp-config`, dropping every ambient\nMCP server, so a spawned agent gets only the servers this file lists, and none with no list. On its\nfirst run `cotal setup` writes the `claude` list from your own Claude Code user-scope servers, leaving\nout a malformed entry and any with an `env` or `headers` value that is anything but `${VAR}`\nreferences, and keeps a list the file already declares.\nEvery shared server boots once per spawn, so remove the heavy ones for a lighter seat.\n\n```json\n{\n "connectors": {\n "claude": {\n "mcpServers": {\n "github": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-github"],\n "env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" }\n }\n }\n }\n }\n}\n```\n\nEach server is written in the de-facto `.mcp.json` shape, so you can copy an entry straight out of\nyour own Claude / VS Code / Cursor config. Secrets ride as **`${VAR}` references** (also\n`${VAR:-default}`), resolved from your environment at launch and forwarded to the child **by name**\n(never as literals) so the file stays safe to keep in `~/.config` or a gitignored `.cotal/`. Only\n`command`, `args`, `env`, `url`, and `headers` are expanded; any other key passes through verbatim.\n\nA server that cannot launch as written is refused when the file is read, with its path in the file\n(`connectors.<name>.mcpServers.<server>.<field>`). `command`, `type` and `url` must be strings, `args`\na list of strings, and `env` and `headers` objects of strings. A server must also name a transport to\nstart: a non-empty `command` when `type` is absent or `stdio`, or a non-empty `url` when it is `http`,\n`sse` or `ws`. Any other `type` is refused. Every spawn reads the whole file, so one such entry\nrefuses every spawn until it is fixed, `--share-tools none` included.\n\n**`--share-tools` interplay**. The per-spawn selection narrows what this config declares:\n\n| `--share-tools` | Result |\n|---|---|\n| (flag absent) | Every server declared for the connector |\n| `none` or empty | Nothing |\n| `a,b` | Only those named: each **must** be declared, or the spawn fails (no silent drop) |\n\nA `supervise --roster` entry selects the same way with a `share-tools:` list: an absent key\nshares every declared server, `[]` shares none, and `[a, b]` shares only those named. Each list\nentry is a server name as written, so `[none]` shares a server declared as `none`.\n\nToday only the `claude` connector consumes shared MCP servers; OpenCode inherits config through its\nown merge layer and Hermes has no MCP. See [Connect Claude Code](connect-claude.md) for the full\nsharing model.\n\n### Model policy\n\n`modelPolicy` names, per role, the models a seat in that role may launch on. Each key is a role.\nIts `models` list holds the allowed model ids, and an optional `variants` list holds the allowed\nvariants.\n\n```json\n{\n "modelPolicy": {\n "reviewer": { "models": ["vendor/model-B"], "variants": ["high"] }\n }\n}\n```\n\n`cotal spawn` and the manager behind `cotal spawn --detach` check it before anything is minted or\nlaunched. They judge the effective role (a `--role` override counts) and the effective model and\nvariant (`--model` and `--variant` win over the persona\'s fields, as always). The spawn is refused\nwhen the role has an entry and:\n\n- no model resolves at all, since the harness would then pick one and nothing would record which;\n- the model is not in `models`. Ids compare whole, so `vendor/model-B-fast` does not match\n `vendor/model-B`;\n- `variants` is set and the variant is absent or not in it;\n- the launch carries any launch option (`launchOptions:` in the persona or manifest, or `--opt`).\n The connector applies launch options unread, after the model and variant, and one can select\n another model (an OpenCode `model`, a Claude `--model`), so a role under the policy launches\n without them.\n\nThe refusal names the persona, whether the value came from its own field or from the flag, the\nvalue, and the allowed ids. Roles with no entry, and personas with no role, are not constrained.\nA seat launches on the model and variant that were checked, even if its persona file changes after\nthe check.\n\nA space-local entry for a role replaces the operator-level entry for that role, and a role named in\nonly one file keeps its entry. A policy that cannot be read as written (a `models` list that is\nempty or holds a non-string, or an unknown field) fails every spawn with an error naming the file.\nThe policy is read at each spawn, so an edit applies to the next spawn without a restart. Seats that\nare already running are not re-checked, including a supervised restart or a preserved seat resumed\nafter maintenance.\n\n## Environment variables\n\nThese are the operator-facing variables. Most of the connector-session ones (space, name, role, \u2026)\nare set **for you** by `cotal spawn` / the manager when they launch an agent; you set them by hand\nonly when you drive a connector session yourself (e.g. your own `claude` with the plugin) or a custom\nlauncher. Comma-separated lists are trimmed. The connector on/off flags (`COTAL_CHANNEL`,\n`COTAL_EVENTS`, `COTAL_EVENTS_REQUIRED`, `COTAL_CODEX_TUI`, `COTAL_JCODE_TUI`) take `1`, `true`,\n`yes` or `on` and `0`, `false`, `no` or `off` in any case. The connector refuses any other value\nwhen it starts, naming the variable.\n\n| Variable | Consumed by | Meaning | Default |\n|---|---|---|---|\n| `COTAL_SPACE` | connector session | Space to join | `demo` (or the join link\'s) |\n| `COTAL_NAME` | connector session | Presence name / identity | required (or via `COTAL_AGENT_FILE` / `COTAL_LINK`) |\n| `COTAL_ROLE` | connector session | Role | agent file\'s `role:`, else none |\n| `COTAL_SERVERS` | connector session | Broker URL(s). Hand-driven sessions only: a launcher-spawned seat gets this in its launch material instead (see below) | the default local broker (or the link\'s) |\n| `COTAL_CREDS` | connector session | Path to a NATS creds file (auth mode). Hand-driven sessions only, same as above | none (open mode) |\n| `COTAL_LINK` | connector session | `cotal://token@host/space` join link: supplies server, auth, space | none |\n| `COTAL_AGENT_FILE` | connector session | Path to a persona file: supplies name, role, kind, channels | none |\n| `COTAL_SUBSCRIBE` | connector session | Active channel read set. Set but empty means no channels | agent file / link, else no channels |\n| `COTAL_ALLOW_SUBSCRIBE` | connector session | Read ACL (channels the agent *may* read). Set but empty means the read set alone | agent file\'s, else = `COTAL_SUBSCRIBE` |\n| `COTAL_ALLOW_PUBLISH` | connector session | Post ACL (channels the agent *may* post to). Set but empty means deny | agent file\'s, else deny (empty) |\n| `COTAL_MODEL` | connector session | Model label (display metadata), set by the launcher | none |\n| `COTAL_KIND` | connector session | Endpoint kind | `agent` |\n| `COTAL_TLS` | connector session | Connect over TLS (`1`) | off |\n| `COTAL_TOKEN` | connector session | Auth token (token / open modes) | none |\n| `COTAL_CAPABILITIES` | connector session | Control-plane capabilities (e.g. `spawn`) that gate manager tools | agent file\'s `capabilities:` |\n| `COTAL_QUIET` / `COTAL_MUTED` | connector session | Per-channel attention defaults (never-wake / drop-on-receive) | agent file\'s, else none |\n| `COTAL_CHANNEL` | Claude connector | Force channel wake-nudges on (`1`) / off; set to `1` by the Claude launcher | on when the MCP client declares the `claude/channel` capability in `initialize` |\n| `COTAL_EVENTS` | connector session | Arm this session\'s event plane (`1`); set by the launcher unless the launch used `--no-events` | launcher-managed |\n| `COTAL_EVENTS_REQUIRED` | hand-driven user-mode connector | Trusted registration says events are mandatory; arms the plane and refuses if the session grant omits its event channel. Launcher-managed sessions carry this in launch material instead | off |\n| `COTAL_DEFAULT_AGENT` | `cotal spawn` | Default connector type for a bare spawn (below an explicit `--agent` and the persona\'s `agent:` pin) | `claude` |\n| `COTAL_DEFAULT_PERSONA` | `cotal spawn` | Default persona for a bare spawn | `default` |\n| `COTAL_SKIP_CONNECTOR_SEED` | boot gate | Skip the automatic built-in-connector seed/refresh on a command (`1`); `cotal ext seed` still works. `agent-bearer` skips the gate by name, no flag needed | off |\n| `COTAL_ALLOW_CHECKOUT_SEED` | seed store | Permit a source-checkout CLI to write the operator-global seed store (`1`) after isolating `$XDG_CONFIG_HOME`. Used by in-tree seed smokes that spawn the checkout-shaped `bin/` CLI into a scratch config. Any other value is ignored. The checkout refusal does not name this variable. | off |\n| `COTAL_DETACH_KEY` | `cotal attach` | Detach escape key (ctrl-<char> / ^<char>), matched as the control byte or its kitty / modifyOtherKeys encoding | `ctrl-]` |\n| `COTAL_FEEDBACK_KEY` | `feedback`, connector | Beta feedback key \u2192 keyed intake | none (public intake) |\n| `COTAL_FEEDBACK_EMAIL` | `feedback`, connector | Contact email for the keyless public intake | your git email |\n| `COTAL_FEEDBACK_URL` | `feedback`, connector | Intake URL override (self-hosted) | keyed / public intake |\n| `COTAL_SKIP_ASSIST` | `setup` | Disable the connector debug handoff on a failed step (`1`; for CI) | off |\n| `COTAL_COMPLETE_DEBUG` | `completion` | Print completion-resolution errors to stderr | off |\n| `COTAL_ENROLLMENT_FILE` | foreground `spawn` | Private `0600` file containing one remote enrollment URL; preferred over the environment form | none |\n| `COTAL_MANAGED_HANDOFF_FILE` | `cotal` entry, foreground `spawn` | Private `0600` file holding one managed lifecycle handoff from a delegating runtime; taken and deleted before anything else runs | none |\n| `COTAL_ENROLLMENT_URL` | foreground `spawn` | One remote enrollment URL when a secret file cannot be mounted; conflicts with `COTAL_ENROLLMENT_FILE` | none |\n| `COTAL_SERVE_HEADLESS` | OpenCode runtime | Run the OpenCode server without a foreground TUI (`1`). Stdout gets one `[cotal-serve]` line with the server\'s port and session and no password; a host that drives the server passes its own `OPENCODE_SERVER_PASSWORD` to the launcher | off |\n| `COTAL_HOME` | workspace | Override the machine-home dir for the **mesh registry only** (`meshes/`, `current-mesh`, onboard marker). Does **not** redirect project-root paths (`findCotalRoot` / `.cotal/broker-policy.json`, NATS store, manager/delivery state, auth). Tests that run `cotal up` must also use a temp project root with its own `.cotal/` as `cwd` | `~/.cotal` |\n\n> `--console-port` is a `cotal supervise` flag, not an environment variable; there is no\n> `COTAL_CONSOLE_PORT`.\n\n### Launcher variables\n\nThese are wired into a spawned child\'s environment by the connector / launcher and read back inside\nthe session. They are not operator knobs; listed so you recognize them in a process listing.\n\n| Variable | Purpose |\n|---|---|\n| `COTAL_ID` | Stable agent id chosen by the launcher (static meshes) |\n| `COTAL_MANAGER_INSTANCE` | Stable instance id of the launching manager. User-auth managed calls request a separate control view for this instance; the issuer authorizes the selection. It carries no credential or grant. An unbound session uses the issuer\'s unique authorized selection |\n| `COTAL_ENVIRONMENT` | Opaque provider-issued environment reference published in presence. Read once when the endpoint is constructed; omitted when the launcher sets none |\n| `COTAL_LIFECYCLE_UID` | The incarnation\'s lifecycle UID, minted once per spawn; the session binds its lifecycle-keyed DM/delivery/history consumers by it (its credential pins the same names). Required for an authed launch (`COTAL_CREDS` or user-mode); config parsing fails loud without it. Open mode omits it (the endpoint self-mints per session) |\n| `COTAL_BACKFILL_FLOOR` | The CHAT stream sequence a resumed seat\'s prior incarnation had reached before its preservation cut; the boot backfill reads only what came after it. Set by the manager on a preserved resume, absent on a fresh spawn. Must parse as a non-negative integer; a broken launcher\'s malformed value fails loud rather than silently falling back to a full replay |\n| `COTAL_OWNER` / `COTAL_ACTOR` / `COTAL_SENTINEL_CREDS` / `COTAL_BEARER_CMD` | User-auth launch identity: the agent\'s principal, its sentinel creds path, and the exec-able bearer command; all four together, mutually exclusive with `COTAL_CREDS`. A launcher-spawned seat carries them in its launch material instead of its environment. A remote enrollment\'s bearer argv uses `agent-bearer --exchange-url <https://base>`; the token never falls back to a local service file |\n| `COTAL_LAUNCH_MATERIAL` | Path to this launch\'s private 0600 material file (see [Launch material](#launch-material) below). Carries the broker URL, the creds path, the auth token, the user-auth identity, the required-events flag, and the control token. A PATH, never a secret |\n| `COTAL_CONTROL_SOCKET` | The session\'s local control endpoint path. The MCP server listens on it and the lifecycle hooks connect to it; the token that authenticates the first frame rides the launch material, not the environment |\n| `COTAL_BRIDGE_SOCKET` / `COTAL_TOOLS_FILE` / `COTAL_PARENT_PID` | Hermes sidecar plumbing (bridge socket, generated tool descriptors, launcher pid to watch). The bridge socket\'s first frame carries the control token from the launch material (or `COTAL_CONTROL_TOKEN` in standalone mode) |\n| `OPENCODE_CONFIG_CONTENT` | Inline OpenCode config (the injected cotal plugin, highest merge layer) |\n| `OPENCODE_DB` / `OPENCODE_HOME` / `OPENCODE_PORT` / `OPENCODE_SERVER_URL` / `COTAL_OPENCODE_*` | OpenCode server plumbing (home, port, DB, server URL) |\n\nA spawned agent receives a fixed OS execution allow-list (PATH, HOME, TERM, locale, and\nXDG/Windows config directories), the machine-wide `COTAL_*` operator knobs (`COTAL_HOME`, the\nfeedback set, the default-agent pair, the `*_BIN` overrides, the timing knobs), the provider inputs\nits connector declares, and `${VAR}` names an explicitly shared MCP server requires. It does not\ninherit the manager\'s ambient environment. This keeps host-session markers such as\n`CLAUDE_CODE_CHILD_SESSION` / `CLAUDECODE` (and the analogous names other hosts use to mark a nested\nsession), unrelated service secrets, and environment-only capabilities out of seats unless\ndeliberately supplied. A seat\'s transcript/resume behaviour is a property of the seat, never of how\nmany layers up someone once ran `cotal up` inside an agent. Connection material is not in the\nenvironment at all (see [identity & auth](identity-and-auth.md)).\n\nEnrollment inputs are launcher-only secrets. `spawn` removes both enrollment variable names from the\nconnector\'s child environment, and `spawn.env` cannot list them.\n\nPATH is forwarded whole, including entries such as `~/.local/bin` where connector binaries live, so\na seat can still launch after the strip. There is no inherit mode and no opt-in-to-containment flag:\nthe allow-list is the only path.\n\nTo deliberately add an environment name for a spawned agent, declare `spawn.env` in the config file:\n\n```json\n{ "spawn": { "env": ["MY_PROVIDER_API_KEY"] } }\n```\n\nThe listed names are added to the fixed boundary. That is also the opt-in for a host-session marker\na persona has chosen to receive (`CLAUDE_CODE_CHILD_SESSION` and friends). A `COTAL_` name other\nthan the machine-wide knobs above is refused and the seat is not launched: the launcher sets those\nnames for each seat, so a value from the spawning process would become the seat\'s role, model or\nread set. An empty array adds nothing. A space-local `spawn` block replaces the operator-level one\noutright rather than merging, so a local list stays local. No `spawn` block, `"spawn": { "env": [] }`, and `"spawn": {}`\nall add no names. A `spawn` that is not an object, or an `env` that is not a list of non-empty names,\nis refused when the file is read, with the file and the field named, so every spawn and resume is\nrefused until it is fixed.\n\nBe honest with yourself about what this buys: `HOME` is forwarded, so an agent with a shell reads\n`~/.aws`, `~/.ssh` and `~/.config` regardless. The boundary protects what a file on disk cannot hand\nover anyway, and that is more than a list of secret values. Some variables are **capability\nhandles**: they do not contain a secret, they name a live process that will act on your behalf.\n`SSH_AUTH_SOCK` is the sharp one. Inherit it and the agent can ask your `ssh-agent` to sign, which\nmeans it can reach any host or sign any commit that key authorises, and it keeps that power even\nif the private key file is not on disk at all. Nothing under `~/.ssh` has to exist for it to work,\nso "a shell reads `~/.ssh` regardless" does not cover this case. The same shape covers a\n`gpg-agent` socket and the desktop and cloud credential brokers. So the default boundary protects:\nsecrets that live **only** in the environment, such as an `aws-vault exec` or `op run` shell or\nCI-injected values, and the capability handles above, which it removes along with everything else\nit does not name. Real containment is still a sandbox or a VM.\n\nModel discovery is the exception, and it is deliberate rather than an oversight. When the `codex` or\n`opencode` connector enumerates a model catalog (`cotal models`, and the manager\'s selector), it runs\nthat harness with your environment minus Cotal\'s own `COTAL_*`, and it does **not** consult\n`spawn.env`. Those probes are short-lived catalog reads rather than agent seats, so an allow-list\nthat confines a seat does not confine them.\n\n### Launch material\n\nA process environment is inherited by every descendant. A seat launched with its credential, its\nbroker URL and its control token in the environment hands all three to the build it runs, the linter,\nthe third-party CLI, the test suite that reads its broker from the environment. Nothing in that chain\nasked for any of it.\n\nSo a launcher-spawned seat does not get them in its environment. The launcher writes them to a single\n**0600 file inside a 0700 private directory** and exports only its path, as `COTAL_LAUNCH_MATERIAL`.\nThe session reads the launch-material file once at startup. For a managed creds path, it reads the\ncredential once to pin the seat\'s nkey, then keeps the path as a renewal source. A re-signed file is\nread by renewal and by reconnect after the cached credential expires, and a file for a different\nnkey is refused. An unbounded credential has no renewal point and remains a static boot-time value.\nThis is the same shape\n`cotal agent-bearer` already uses for its spawn-time secret: the material rides a file, never argv\n(which is visible in a process listing) and never the ambient environment (which is inherited).\n\nThree connectors drop the path once they have read it, so the shells and tools those seats run\ninherit no reference at all: **pi** and **codex**, whose sessions run in the seat process, and\n**OpenCode**, whose seat process is a shim that starts `opencode serve` (the plugin runs in that\nserver, which is also what executes the session\'s tool calls). Those three also **delete the file**\nat the same moment, along with the private directory that held it. Nothing reads it again, so leaving\nit on disk would only extend how long a copy of the material exists. The directory is only removed\nwhen it is provably the one the launcher wrote: the right filename inside, the launcher\'s prefix on\nthe directory, the directory sitting directly in the OS temp root, and a non-recursive removal that\nfails rather than deletes if anything else is in there.\n\nTwo keep it, and for the same reason in both cases: a process that starts LATER has to read it.\n**Claude**\'s readers are short-lived children, the MCP server and one process per lifecycle hook,\nwhich begin after the session is already running. **Hermes**\' launcher starts a gateway child that\nneeds the control token. For those two, a shell the seat runs still inherits a path to the material\nfile, though not the material itself.\n\nWhat this does: the values are out of every descendant\'s environment, so an `env` dump, a CI log, a\nsuite that defaults its broker from the environment, or a tool handed a credential it never asked\nfor, all stop seeing them. What it does not do: hide the material from a process running as the same\nuser that deliberately opens the file. No environment-level control can, and the same is already true\nof `~/.cotal/auth/creds`. What changes is that reaching the material is a deliberate act rather than\nan inheritance nobody chose.\n\nDriving a connector session **by hand** still works the documented way: set `COTAL_CREDS` /\n`COTAL_SERVERS` (and the user-auth quartet) yourself, and no material file is involved. Setting both\na material file and any of them is refused rather than resolved by precedence: one launch carries one\nidentity plane. `COTAL_LINK` counts as one of them, because a join link carries the server, the auth\nand the space in a single string.\n\n`eventsRequired` is an additive boolean in launch material. The launcher derives it from the selected\nuser-auth registration. Connector config exposes it and the Claude and OpenCode startup gates arm on\nit even when `COTAL_EVENTS` is absent. A direct env launch may use `COTAL_EVENTS_REQUIRED=1` only with\nthe complete user-auth quartet. The session refuses if its post ACL does not cover its own\n`events.<owner>.<actor>` channel.\n\nThe control endpoint is a pair, and **half a pair is refused**. A launch with a control socket path\nand no resolvable token, or a token and no socket path, does not fall back to running without a\ncontrol plane: it fails with a sentence naming which half is missing. The one exception is the\nlifecycle hook relay, which catches that refusal, writes a single warning to stderr naming no values,\nand then does nothing, because a hook that throws is a hook that blocked the session. Failing open is\ndeliberate; failing open silently is not.\n\n## On-disk layout\n\n### Project files\n\nA project\'s state lives in `.cotal/` at the mesh root (found by walking up from the cwd, like `.git`).\n**It is gitignored**; it holds secrets and machine-local process state.\n\n| Path | What it is |\n|---|---|\n| `auth/broker.json` | Broker trust material: the operator seed and the system account (secret; the system-account signing seed is stripped before writing). One per broker, shared by every space on it |\n| `auth/account.<key>.json` | One space\'s own NATS data account and signing seed (secret). One file per space, all signed by the broker above; `<key>` is a stable, case-safe hex encoding of the space name (never the raw name, so two case-differing spaces can\'t collide) |\n| `auth/space.<key>/` | One space\'s user-auth state (IdP pin, issuer keys, owner secret, callout account), present only when that space enables per-user auth. Keyed by the same case-safe hex encoding; pre-hex layouts (`auth/<space>/`) are renamed here on first touch |\n| `auth/creds/space.<key>/<name>.creds` | Per-agent minted NATS credentials, under the segment of the space they belong to - same case-safe hex encoding as the rows above. Pre-segment layouts (`auth/creds/<name>.creds`) are moved here on first touch. The `creds` directory itself stays shared, so a root\'s tenants keep their agent material in sibling segments rather than sibling roots |\n| `auth/server.conf` | Generated nats-server config for the broker (`# Generated by \\`cotal up\\` - do not edit by hand.`). Path is `<projectRoot>/.cotal/auth/server.conf`, not `~/.cotal` unless that is the mesh root. Default bind is loopback (`host: 127.0.0.1`); `--host` on a **stopped** `cotal up` regenerates it. A live refresh does not rewrite this file. The core renderer accepts every space on the broker; `cotal up` currently orchestrates one space per root, so it renders that one space\'s account |\n| `broker-policy.json` | Durable broker **launch** policy (TLS-required cert/key path references, or plaintext). Survives `cotal down` so a bare re-`up` cannot silently drop TLS. Under the project root: **not** under `COTAL_HOME` |\n| `agents/<name>.md` | Persona / agent files ([Agent files](agent-files.md)) |\n| `manifests/<hash>.json` | Manifest-deploy ledger (records of `up -f` / `spawn -f` runs) |\n| `config.json` | Space-local connector config (the override layer above) |\n| `nats.pid` \xB7 `nats.log` | Background nats-server pid + log |\n| `manager.<key>.pid` \xB7 `manager.<key>.log` | Manager (supervisor) pid + log for one space; every line of the log starts with the UTC time it was written (ISO 8601), so a reap can be placed in time without another file; `manager.<key>.delivery-aware` marks a delivery-aware build. `<key>` is the same case-safe hex space key as the rows above, so one root can run a manager per space. A pre-segmentation root-scoped `manager.pid` is still read while it is the only spelling present, and is removed as the new record is written. A start that finds it already removed, by its exiting owner or by a concurrent start, continues. Both spellings present is reported as ambiguous rather than guessed. The manager writes the pid itself, whatever started it, and removes it on a clean stop only while it still names that process. A reader treats the record as a running manager only if the pid is alive **and** the process is a supervisor: a recycled pid belonging to something else is reported as a stale record, never signalled |\n| `delivery.<key>.pid` \xB7 `delivery.<key>.log` \xB7 `space.<key>/delivery.creds` | Delivery daemon pid and log for one space, and its scoped cred (auth mode). `cotal down` and the teardown of a foreground `cotal up` remove the cred once the daemon is confirmed stopped. Per-space and compatible with a pre-segmentation `delivery.pid` on the same terms as the manager row |\n| `web.pid` \xB7 `web.log` | Web dashboard pid + log |\n| `membership.json` \xB7 `membership-*.creds` | Membership feed state + its scoped creds |\n| `setup.log` | `cotal setup` log, one section appended per run. Each entry is one timestamped line: a control character or Unicode line separator in a path or error message is written as a `\\uXXXX` escape |\n\nA command that acts on the whole folder without being told a space reads one off these runtime\nrecords: `<key>` decodes back to the space name, and a space whose record is running wins over\nresidue from a stopped one. Two spaces running under one root is reported rather than arbitrated.\nThis is what lets `cotal status` and `cotal down` work in a folder whose mesh runs with\n`broker: { auth: false }`, where there is no `auth/account.<key>.json` to name the space.\nA record, or a `.cotal` listing, that exists but cannot be read is reported as that read error\nrather than read as absent.\n\n### Machine files\n\nCross-project machine state, so a `cotal spawn` from any directory can find a running mesh. Location:\n`~/.cotal` on POSIX, `%LOCALAPPDATA%\\Cotal` on Windows; overridable with `COTAL_HOME`.\n\n`COTAL_HOME` overrides **this tree only** (registry + current pointer + onboard marker). It is not a\nfull workstation sandbox. Broker launch policy, the JetStream store, pidfiles, and auth live under\nthe **project** `.cotal/` found by walking up from the cwd ([Project: `.cotal/`](#project-files)\nabove, including `broker-policy.json` on TLS meshes). A probe that sets `COTAL_HOME` alone and runs\n`cotal up --tls-cert \u2026` from a directory whose walked root is the operator home still writes those\nproject paths on the live machine.\n\n| Path | What it is |\n|---|---|\n| `meshes/space.<key>.json` | Registry of running meshes: one file per broker `cotal up` started (server URL, root path, mode, TLS-required client intent when recorded, attach bind host and live-session ceiling when the operator set them); `<key>` is the same case-safe hex encoding of the space name, and the record\'s own `space` field is authoritative |\n| `current-mesh` | Default space a bare `cotal spawn` joins (set by `cotal use`) |\n| `onboarded.json` | First-run marker (with `ONBOARD_VERSION`) that flips setup between first-run and status-card |\n| the Claude plugin marketplace | The installed `cotal-mesh` plugin assets |\n\n### Configuration files\n\nDistinct from `~/.cotal`. Location: `$XDG_CONFIG_HOME/cotal`, else `~/.config/cotal` on POSIX, or\n`%APPDATA%\\Cotal` on Windows.\n\n| Path | What it is |\n|---|---|\n| `config.json` | Operator-level connector config (the base layer above) |\n| `extensions/` | `cotal ext` install prefix: its own npm root (`node_modules`) plus an `extensions.json` provider/command-display cache. Built-in connectors install here too, seeded on first run |\n| `seed/` | Built-in-connector seeding state: the `ever-seeded` authority (+ durable backup), the init witness, the version stamp, the crash cursor, and `store/<version>/<name>` (the stable payloads `ext add --install-links` reifies each seeded connector from) |\n\nBoth `extensions/` and `seed/store/` are operator-global: shared by every space, project directory, and\ncheckout on the machine, and moved only by `$XDG_CONFIG_HOME` (a fresh project dir isolates `.cotal/`,\nnot these). `COTAL_HOME` does not relocate them. A CLI running from a source checkout (`pnpm cotal`,\n`tsx bin/cotal.ts`, `node bin/cotal.ts`, or a suite child of those, identified by a `bin/` package\nroot next to `implementations/` or `pnpm-workspace.yaml`) refuses to write, stamp, or\ngarbage-collect that store: the refusal names the store path, the generation it declined, and\n`COTAL_SKIP_CONNECTOR_SEED=1` as the way to run other commands from a checkout. An entry that\ncannot be proven as a released `cotal-ai` install is refused the same way. Isolating\n`$XDG_CONFIG_HOME` (on Windows, `%APPDATA%`) keeps a checkout off the installed config but does not\nlift the refusal by itself. A\nreleased install or an `npx` unpack still seeds as before. The in-tree seed smokes that must seed\nfrom a checkout-shaped `bin/` set `COTAL_ALLOW_CHECKOUT_SEED=1` against an isolated config; an\nopt-in write still records that checkout path in `seed/stamp.json` as `writtenBy`. The reconcile\nnames on stderr both the store payloads it writes and any old generation it removes, so a\nmachine-wide re-seed or cleanup is visible when it happens. Those lines are provenance output. When a\nstderr write fails, at once or after waiting in a full pipe, the line is printed on stdout with the\nerror and the reconcile still completes. If stdout fails too, the reconcile still completes and the\nrun exits 1 instead of 0. A line still waiting in a full stderr pipe when the run exits, as when the\nCLI exits on a closed stdout, is lost and also makes the run exit 1. Node does not say which stderr\nbytes are still waiting, so a line that had to wait and got through just before the exit also makes\nthe run exit 1 when later stderr output is still waiting. A run whose stderr is closed or redirected\naway at launch keeps the write and loses the line.\n\nFor how `cotal setup` populates the machine state and the plugin, and how the built-in connectors are\nseeded as removable extensions, see [setup internals](setup-internals.md).\n'
62543
+ "body": '# Configuration\n\n> **Reference**: describes the TypeScript reference implementation (the `cotal` CLI and connectors), not the wire contract. \xB7 **For:** operators \xB7 **Wire contract:** [SPEC](../SPEC.md)\n\nThree things configure a Cotal workstation: the **config file** (per-connector settings, notably\nwhich of your MCP servers get shared with spawned agents), a set of **`COTAL_*` environment\nvariables**, and the **on-disk layout** under a project\'s `.cotal/` and your machine\'s `~/.cotal`.\nNone of these are part of the wire contract; they configure the reference implementation only.\n\n## The config file\n\nThe cotal config file carries per-connector launch settings. It is layered from two locations,\nmost-specific-wins:\n\n| Layer | Path | Scope |\n|---|---|---|\n| Base | `$XDG_CONFIG_HOME/cotal/config.json` (else `~/.config/cotal/config.json`; `%APPDATA%\\Cotal\\config.json` on Windows) | Operator-level, every space |\n| Override | `<project-root>/.cotal/config.json` | Space-local |\n\nThey merge per connector and per server name: a server in the space-local file replaces the\nsame-named server in the operator-level file; connectors or servers present in only one side are\nkept. A missing file is empty (valid); malformed JSON, a non-object top level, or a shared server\nthat cannot launch as written is a loud error.\n\nIt carries three things: which of your personal MCP servers a connector should **share** with the\nagents it spawns, optional `spawn.env` names that deliberately add environment capability to a\nspawned agent (see [Environment variables](#environment-variables) below), and an optional\n`modelPolicy` that limits which models a role may launch on (see [Model policy](#model-policy)).\n\nThe sharing half: the Claude connector launches with `--strict-mcp-config`, dropping every ambient\nMCP server, so a spawned agent gets only the servers this file lists, and none with no list. On its\nfirst run `cotal setup` writes the `claude` list from your own Claude Code user-scope servers, leaving\nout a malformed entry and any with an `env` or `headers` value that is anything but `${VAR}`\nreferences, and keeps a list the file already declares.\nEvery shared server boots once per spawn, so remove the heavy ones for a lighter seat.\n\n```json\n{\n "connectors": {\n "claude": {\n "mcpServers": {\n "github": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-github"],\n "env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" }\n }\n }\n }\n }\n}\n```\n\nEach server is written in the de-facto `.mcp.json` shape, so you can copy an entry straight out of\nyour own Claude / VS Code / Cursor config. Secrets ride as **`${VAR}` references** (also\n`${VAR:-default}`), resolved from your environment at launch and forwarded to the child **by name**\n(never as literals) so the file stays safe to keep in `~/.config` or a gitignored `.cotal/`. Only\n`command`, `args`, `env`, `url`, and `headers` are expanded; any other key passes through verbatim.\n\nA server that cannot launch as written is refused when the file is read, with its path in the file\n(`connectors.<name>.mcpServers.<server>.<field>`). `command`, `type` and `url` must be strings, `args`\na list of strings, and `env` and `headers` objects of strings. A server must also name a transport to\nstart: a non-empty `command` when `type` is absent or `stdio`, or a non-empty `url` when it is `http`,\n`sse` or `ws`. Any other `type` is refused. Every spawn reads the whole file, so one such entry\nrefuses every spawn until it is fixed, `--share-tools none` included.\n\n**`--share-tools` interplay**. The per-spawn selection narrows what this config declares:\n\n| `--share-tools` | Result |\n|---|---|\n| (flag absent) | Every server declared for the connector |\n| `none` or empty | Nothing |\n| `a,b` | Only those named: each **must** be declared, or the spawn fails (no silent drop) |\n\nA `supervise --roster` entry selects the same way with a `share-tools:` list: an absent key\nshares every declared server, `[]` shares none, and `[a, b]` shares only those named. Each list\nentry is a server name as written, so `[none]` shares a server declared as `none`.\n\nToday only the `claude` connector consumes shared MCP servers; OpenCode inherits config through its\nown merge layer and Hermes has no MCP. See [Connect Claude Code](connect-claude.md) for the full\nsharing model.\n\n### Model policy\n\n`modelPolicy` names, per role, the models a seat in that role may launch on. Each key is a role.\nIts `models` list holds the allowed model ids, and an optional `variants` list holds the allowed\nvariants.\n\n```json\n{\n "modelPolicy": {\n "reviewer": { "models": ["vendor/model-B"], "variants": ["high"] }\n }\n}\n```\n\n`cotal spawn` and the manager behind `cotal spawn --detach` check it before anything is minted or\nlaunched. They judge the effective role (a `--role` override counts) and the effective model and\nvariant (`--model` and `--variant` win over the persona\'s fields, as always). The spawn is refused\nwhen the role has an entry and:\n\n- no model resolves at all, since the harness would then pick one and nothing would record which;\n- the model is not in `models`. Ids compare whole, so `vendor/model-B-fast` does not match\n `vendor/model-B`;\n- `variants` is set and the variant is absent or not in it;\n- the launch carries any launch option (`launchOptions:` in the persona or manifest, or `--opt`).\n The connector applies launch options unread, after the model and variant, and one can select\n another model (an OpenCode `model`, a Claude `--model`), so a role under the policy launches\n without them.\n\nThe refusal names the persona, whether the value came from its own field or from the flag, the\nvalue, and the allowed ids. Roles with no entry, and personas with no role, are not constrained.\nA seat launches on the model and variant that were checked, even if its persona file changes after\nthe check.\n\nA space-local entry for a role replaces the operator-level entry for that role, and a role named in\nonly one file keeps its entry. A policy that cannot be read as written (a `models` list that is\nempty or holds a non-string, or an unknown field) fails every spawn with an error naming the file.\nThe policy is read at each spawn, so an edit applies to the next spawn without a restart. Seats that\nare already running are not re-checked, including a supervised restart or a preserved seat resumed\nafter maintenance.\n\n## Environment variables\n\nThese are the operator-facing variables. Most of the connector-session ones (space, name, role, \u2026)\nare set **for you** by `cotal spawn` / the manager when they launch an agent; you set them by hand\nonly when you drive a connector session yourself (e.g. your own `claude` with the plugin) or a custom\nlauncher. Comma-separated lists are trimmed. The connector on/off flags (`COTAL_CHANNEL`,\n`COTAL_EVENTS`, `COTAL_EVENTS_REQUIRED`, `COTAL_CODEX_TUI`, `COTAL_JCODE_TUI`) take `1`, `true`,\n`yes` or `on` and `0`, `false`, `no` or `off` in any case. The connector refuses any other value\nwhen it starts, naming the variable.\n\n| Variable | Consumed by | Meaning | Default |\n|---|---|---|---|\n| `COTAL_SPACE` | connector session | Space to join | `demo` (or the join link\'s) |\n| `COTAL_NAME` | connector session | Presence name / identity | required (or via `COTAL_AGENT_FILE` / `COTAL_LINK`) |\n| `COTAL_ROLE` | connector session | Role | agent file\'s `role:`, else none |\n| `COTAL_SERVERS` | connector session | Broker URL(s). Hand-driven sessions only: a launcher-spawned seat gets this in its launch material instead (see below) | the default local broker (or the link\'s) |\n| `COTAL_CREDS` | connector session | Path to a NATS creds file (auth mode). Hand-driven sessions only, same as above | none (open mode) |\n| `COTAL_LINK` | connector session | `cotal://token@host/space` join link: supplies server, auth, space | none |\n| `COTAL_AGENT_FILE` | connector session | Path to a persona file: supplies name, role, kind, channels | none |\n| `COTAL_SUBSCRIBE` | connector session | Active channel read set. Set but empty means no channels | agent file / link, else no channels |\n| `COTAL_ALLOW_SUBSCRIBE` | connector session | Read ACL (channels the agent *may* read). Set but empty means the read set alone | agent file\'s, else = `COTAL_SUBSCRIBE` |\n| `COTAL_ALLOW_PUBLISH` | connector session | Post ACL (channels the agent *may* post to). Set but empty means deny | agent file\'s, else deny (empty) |\n| `COTAL_MODEL` | connector session | Model label (display metadata), set by the launcher | none |\n| `COTAL_KIND` | connector session | Endpoint kind | `agent` |\n| `COTAL_TLS` | connector session | Connect over TLS (`1`) | off |\n| `COTAL_TOKEN` | connector session | Auth token (token / open modes) | none |\n| `COTAL_CAPABILITIES` | connector session | Control-plane capabilities (e.g. `spawn`) that gate manager tools | agent file\'s `capabilities:` |\n| `COTAL_QUIET` / `COTAL_MUTED` | connector session | Per-channel attention defaults (never-wake / drop-on-receive) | agent file\'s, else none |\n| `COTAL_CHANNEL` | Claude connector | Force channel wake-nudges on (`1`) / off; set to `1` by the Claude launcher | on when the MCP client declares the `claude/channel` capability in `initialize` |\n| `COTAL_EVENTS` | connector session | Arm this session\'s event plane (`1`); set by the launcher unless the launch used `--no-events` | launcher-managed |\n| `COTAL_EVENTS_REQUIRED` | hand-driven user-mode connector | Trusted registration says events are mandatory; arms the plane and refuses if the session grant omits its event channel. Launcher-managed sessions carry this in launch material instead | off |\n| `COTAL_DEFAULT_AGENT` | `cotal spawn` | Default connector type for a bare spawn (below an explicit `--agent` and the persona\'s `agent:` pin) | `claude` |\n| `COTAL_DEFAULT_PERSONA` | `cotal spawn` | Default persona for a bare spawn | `default` |\n| `COTAL_SKIP_CONNECTOR_SEED` | boot gate | Skip the automatic built-in-connector seed/refresh on a command (`1`); `cotal ext seed` still works. `agent-bearer` skips the gate by name, no flag needed | off |\n| `COTAL_ALLOW_CHECKOUT_SEED` | seed store | Permit a source-checkout CLI to write the operator-global seed store (`1`) after isolating `$XDG_CONFIG_HOME`. Used by in-tree seed smokes that spawn the checkout-shaped `bin/` CLI into a scratch config. Any other value is ignored. The checkout refusal does not name this variable. | off |\n| `COTAL_DETACH_KEY` | `cotal attach` | Detach escape key (ctrl-<char> / ^<char>), matched as the control byte or its kitty / modifyOtherKeys encoding | `ctrl-]` |\n| `COTAL_FEEDBACK_KEY` | `feedback`, connector | Beta feedback key \u2192 keyed intake | none (public intake) |\n| `COTAL_FEEDBACK_EMAIL` | `feedback`, connector | Contact email for the keyless public intake | your git email |\n| `COTAL_FEEDBACK_URL` | `feedback`, connector | Intake URL override (self-hosted) | keyed / public intake |\n| `COTAL_SKIP_ASSIST` | `setup` | Disable the connector debug handoff on a failed step (`1`; for CI) | off |\n| `COTAL_COMPLETE_DEBUG` | `completion` | Print completion-resolution errors to stderr | off |\n| `COTAL_ENROLLMENT_FILE` | foreground `spawn` | Private `0600` file containing one remote enrollment URL; preferred over the environment form | none |\n| `COTAL_MANAGED_HANDOFF_FILE` | `cotal` entry, foreground `spawn` | Private `0600` file holding one managed lifecycle handoff from a delegating runtime; taken and deleted before anything else runs | none |\n| `COTAL_ENROLLMENT_URL` | foreground `spawn` | One remote enrollment URL when a secret file cannot be mounted; conflicts with `COTAL_ENROLLMENT_FILE` | none |\n| `COTAL_SERVE_HEADLESS` | OpenCode runtime | Run the OpenCode server without a foreground TUI (`1`). Stdout gets one `[cotal-serve]` line with the server\'s port and session and no password; a host that drives the server passes its own `OPENCODE_SERVER_PASSWORD` to the launcher | off |\n| `COTAL_HOME` | workspace | Override the machine-home dir for the **mesh registry only** (`meshes/`, `current-mesh`, onboard marker). Does **not** redirect project-root paths (`findCotalRoot` / `.cotal/broker-policy.json`, NATS store, manager/delivery state, auth). Tests that run `cotal up` must also use a temp project root with its own `.cotal/` as `cwd` | `~/.cotal` |\n\n> `--console-port` is a `cotal supervise` flag, not an environment variable; there is no\n> `COTAL_CONSOLE_PORT`.\n\n### Launcher variables\n\nThese are wired into a spawned child\'s environment by the connector / launcher and read back inside\nthe session. They are not operator knobs; listed so you recognize them in a process listing.\n\n| Variable | Purpose |\n|---|---|\n| `COTAL_ID` | Stable agent id chosen by the launcher (static meshes) |\n| `COTAL_MANAGER_INSTANCE` | Stable instance id of the launching manager. User-auth managed calls request a separate control view for this instance; the issuer authorizes the selection. It carries no credential or grant. An unbound session uses the issuer\'s unique authorized selection |\n| `COTAL_ENVIRONMENT` | Opaque provider-issued environment reference published in presence. Read once when the endpoint is constructed; omitted when the launcher sets none |\n| `COTAL_LIFECYCLE_UID` | The incarnation\'s lifecycle UID, minted once per spawn; the session binds its lifecycle-keyed DM/delivery/history consumers by it (its credential pins the same names). Required for an authed launch (`COTAL_CREDS` or user-mode); config parsing fails loud without it. Open mode omits it (the endpoint self-mints per session) |\n| `COTAL_BACKFILL_FLOOR` | The CHAT stream sequence a resumed seat\'s prior incarnation had reached before its preservation cut; the boot backfill reads only what came after it. Set by the manager on a preserved resume, absent on a fresh spawn. Must parse as a non-negative integer; a broken launcher\'s malformed value fails loud rather than silently falling back to a full replay |\n| `COTAL_OWNER` / `COTAL_ACTOR` / `COTAL_SENTINEL_CREDS` / `COTAL_BEARER_CMD` | User-auth launch identity: the agent\'s principal, its sentinel creds path, and the exec-able bearer command; all four together, mutually exclusive with `COTAL_CREDS`. A launcher-spawned seat carries them in its launch material instead of its environment. A remote enrollment\'s bearer argv uses `agent-bearer --exchange-url <https://base>`; the token never falls back to a local service file |\n| `COTAL_LAUNCH_MATERIAL` | Path to this launch\'s private 0600 material file (see [Launch material](#launch-material) below). Carries the broker URL, the creds path, the auth token, the user-auth identity, the required-events flag, and the control token. A PATH, never a secret |\n| `COTAL_CONTROL_SOCKET` | The session\'s local control endpoint path. The MCP server listens on it and the lifecycle hooks connect to it; the token that authenticates the first frame rides the launch material, not the environment |\n| `COTAL_BRIDGE_SOCKET` / `COTAL_TOOLS_FILE` / `COTAL_PARENT_PID` | Hermes sidecar plumbing (bridge socket, generated tool descriptors, launcher pid to watch). The bridge socket\'s first frame carries the control token from the launch material (or `COTAL_CONTROL_TOKEN` in standalone mode) |\n| `OPENCODE_CONFIG_CONTENT` | Inline OpenCode config (the injected cotal plugin, highest merge layer) |\n| `OPENCODE_DB` / `OPENCODE_HOME` / `OPENCODE_PORT` / `OPENCODE_SERVER_URL` / `COTAL_OPENCODE_*` | OpenCode server plumbing (home, port, DB, server URL) |\n\nA spawned agent receives a fixed OS execution allow-list (PATH, HOME, TERM, locale, and\nXDG/Windows config directories), the machine-wide `COTAL_*` operator knobs (`COTAL_HOME`, the\nfeedback set, the default-agent pair, the `*_BIN` overrides, the timing knobs), the provider inputs\nits connector declares, and `${VAR}` names an explicitly shared MCP server requires. It does not\ninherit the manager\'s ambient environment. This keeps host-session markers such as\n`CLAUDE_CODE_CHILD_SESSION` / `CLAUDECODE` (and the analogous names other hosts use to mark a nested\nsession), unrelated service secrets, and environment-only capabilities out of seats unless\ndeliberately supplied. A seat\'s transcript/resume behaviour is a property of the seat, never of how\nmany layers up someone once ran `cotal up` inside an agent. Connection material is not in the\nenvironment at all (see [identity & auth](identity-and-auth.md)).\n\nEnrollment inputs are launcher-only secrets. `spawn` removes both enrollment variable names from the\nconnector\'s child environment, and `spawn.env` cannot list them.\n\nPATH is forwarded whole, including entries such as `~/.local/bin` where connector binaries live, so\na seat can still launch after the strip. There is no inherit mode and no opt-in-to-containment flag:\nthe allow-list is the only path.\n\nTo deliberately add an environment name for a spawned agent, declare `spawn.env` in the config file:\n\n```json\n{ "spawn": { "env": ["MY_PROVIDER_API_KEY"] } }\n```\n\nThe listed names are added to the fixed boundary. That is also the opt-in for a host-session marker\na persona has chosen to receive (`CLAUDE_CODE_CHILD_SESSION` and friends). A `COTAL_` name other\nthan the machine-wide knobs above is refused and the seat is not launched: the launcher sets those\nnames for each seat, so a value from the spawning process would become the seat\'s role, model or\nread set. An empty array adds nothing. A space-local `spawn` block replaces the operator-level one\noutright rather than merging, so a local list stays local. No `spawn` block, `"spawn": { "env": [] }`, and `"spawn": {}`\nall add no names. A `spawn` that is not an object, or an `env` that is not a list of non-empty names,\nis refused when the file is read, with the file and the field named, so every spawn and resume is\nrefused until it is fixed.\n\nBe honest with yourself about what this buys: `HOME` is forwarded, so an agent with a shell reads\n`~/.aws`, `~/.ssh` and `~/.config` regardless. The boundary protects what a file on disk cannot hand\nover anyway, and that is more than a list of secret values. Some variables are **capability\nhandles**: they do not contain a secret, they name a live process that will act on your behalf.\n`SSH_AUTH_SOCK` is the sharp one. Inherit it and the agent can ask your `ssh-agent` to sign, which\nmeans it can reach any host or sign any commit that key authorises, and it keeps that power even\nif the private key file is not on disk at all. Nothing under `~/.ssh` has to exist for it to work,\nso "a shell reads `~/.ssh` regardless" does not cover this case. The same shape covers a\n`gpg-agent` socket and the desktop and cloud credential brokers. So the default boundary protects:\nsecrets that live **only** in the environment, such as an `aws-vault exec` or `op run` shell or\nCI-injected values, and the capability handles above, which it removes along with everything else\nit does not name. Real containment is still a sandbox or a VM.\n\nModel discovery is the exception, and it is deliberate rather than an oversight. When the `codex` or\n`opencode` connector enumerates a model catalog (`cotal models`, and the manager\'s selector), it runs\nthat harness with your environment minus Cotal\'s own `COTAL_*`, and it does **not** consult\n`spawn.env`. Those probes are short-lived catalog reads rather than agent seats, so an allow-list\nthat confines a seat does not confine them.\n\n### Launch material\n\nA process environment is inherited by every descendant. A seat launched with its credential, its\nbroker URL and its control token in the environment hands all three to the build it runs, the linter,\nthe third-party CLI, the test suite that reads its broker from the environment. Nothing in that chain\nasked for any of it.\n\nSo a launcher-spawned seat does not get them in its environment. The launcher writes them to a single\n**0600 file inside a 0700 private directory** and exports only its path, as `COTAL_LAUNCH_MATERIAL`.\nThe session reads the launch-material file once at startup. For a managed creds path, it reads the\ncredential once to pin the seat\'s nkey, then keeps the path as a renewal source. A re-signed file is\nread by renewal and by reconnect after the cached credential expires, and a file for a different\nnkey is refused. An unbounded credential has no renewal point and remains a static boot-time value.\nThis is the same shape\n`cotal agent-bearer` already uses for its spawn-time secret: the material rides a file, never argv\n(which is visible in a process listing) and never the ambient environment (which is inherited).\n\nThree connectors drop the path once they have read it, so the shells and tools those seats run\ninherit no reference at all: **pi** and **codex**, whose sessions run in the seat process, and\n**OpenCode**, whose seat process is a shim that starts `opencode serve` (the plugin runs in that\nserver, which is also what executes the session\'s tool calls). Those three also **delete the file**\nat the same moment, along with the private directory that held it. Nothing reads it again, so leaving\nit on disk would only extend how long a copy of the material exists. The directory is only removed\nwhen it is provably the one the launcher wrote: the right filename inside, the launcher\'s prefix on\nthe directory, the directory sitting directly in the OS temp root, and a non-recursive removal that\nfails rather than deletes if anything else is in there.\n\nTwo keep it, and for the same reason in both cases: a process that starts LATER has to read it.\n**Claude**\'s readers are short-lived children, the MCP server and one process per lifecycle hook,\nwhich begin after the session is already running. **Hermes**\' launcher starts a gateway child that\nneeds the control token. For those two, a shell the seat runs still inherits a path to the material\nfile, though not the material itself.\n\nWhat this does: the values are out of every descendant\'s environment, so an `env` dump, a CI log, a\nsuite that defaults its broker from the environment, or a tool handed a credential it never asked\nfor, all stop seeing them. What it does not do: hide the material from a process running as the same\nuser that deliberately opens the file. No environment-level control can, and the same is already true\nof `~/.cotal/auth/creds`. What changes is that reaching the material is a deliberate act rather than\nan inheritance nobody chose.\n\nDriving a connector session **by hand** still works the documented way: set `COTAL_CREDS` /\n`COTAL_SERVERS` (and the user-auth quartet) yourself, and no material file is involved. Setting both\na material file and any of them is refused rather than resolved by precedence: one launch carries one\nidentity plane. `COTAL_LINK` counts as one of them, because a join link carries the server, the auth\nand the space in a single string.\n\n`eventsRequired` is an additive boolean in launch material. The launcher derives it from the selected\nuser-auth registration. A Claude, OpenCode, Codex, Jcode or Pi launch that carries it also sets\n`COTAL_EVENTS` and the workspace root, even when the launcher passed `events: false`. Connector config\nexposes it and the Claude, OpenCode 1.x, Codex, Jcode and Pi startup gates arm on it even when\n`COTAL_EVENTS` is absent. A direct env launch may use `COTAL_EVENTS_REQUIRED=1` only with the complete\nuser-auth quartet. The session refuses if its post ACL does not cover its own `events.<owner>.<actor>`\nchannel.\n\nThe control endpoint is a pair, and **half a pair is refused**. A launch with a control socket path\nand no resolvable token, or a token and no socket path, does not fall back to running without a\ncontrol plane: it fails with a sentence naming which half is missing. The one exception is the\nlifecycle hook relay, which catches that refusal, writes a single warning to stderr naming no values,\nand then does nothing, because a hook that throws is a hook that blocked the session. Failing open is\ndeliberate; failing open silently is not.\n\n## On-disk layout\n\n### Project files\n\nA project\'s state lives in `.cotal/` at the mesh root (found by walking up from the cwd, like `.git`).\n**It is gitignored**; it holds secrets and machine-local process state.\n\n| Path | What it is |\n|---|---|\n| `auth/broker.json` | Broker trust material: the operator seed and the system account (secret; the system-account signing seed is stripped before writing). One per broker, shared by every space on it |\n| `auth/account.<key>.json` | One space\'s own NATS data account and signing seed (secret). One file per space, all signed by the broker above; `<key>` is a stable, case-safe hex encoding of the space name (never the raw name, so two case-differing spaces can\'t collide) |\n| `auth/space.<key>/` | One space\'s user-auth state (IdP pin, issuer keys, owner secret, callout account), present only when that space enables per-user auth. Keyed by the same case-safe hex encoding; pre-hex layouts (`auth/<space>/`) are renamed here on first touch |\n| `auth/creds/space.<key>/<name>.creds` | Per-agent minted NATS credentials, under the segment of the space they belong to - same case-safe hex encoding as the rows above. Pre-segment layouts (`auth/creds/<name>.creds`) are moved here on first touch. The `creds` directory itself stays shared, so a root\'s tenants keep their agent material in sibling segments rather than sibling roots |\n| `auth/server.conf` | Generated nats-server config for the broker (`# Generated by \\`cotal up\\` - do not edit by hand.`). Path is `<projectRoot>/.cotal/auth/server.conf`, not `~/.cotal` unless that is the mesh root. Default bind is loopback (`host: 127.0.0.1`); `--host` on a **stopped** `cotal up` regenerates it. A live refresh does not rewrite this file. The core renderer accepts every space on the broker; `cotal up` currently orchestrates one space per root, so it renders that one space\'s account |\n| `broker-policy.json` | Durable broker **launch** policy (TLS-required cert/key path references, or plaintext). Survives `cotal down` so a bare re-`up` cannot silently drop TLS. Under the project root: **not** under `COTAL_HOME` |\n| `agents/<name>.md` | Persona / agent files ([Agent files](agent-files.md)) |\n| `manifests/<hash>.json` | Manifest-deploy ledger (records of `up -f` / `spawn -f` runs) |\n| `config.json` | Space-local connector config (the override layer above) |\n| `nats.pid` \xB7 `nats.log` | Background nats-server pid + log |\n| `manager.<key>.pid` \xB7 `manager.<key>.log` | Manager (supervisor) pid + log for one space; every line of the log starts with the UTC time it was written (ISO 8601), so a reap can be placed in time without another file; `manager.<key>.delivery-aware` marks a delivery-aware build. `<key>` is the same case-safe hex space key as the rows above, so one root can run a manager per space. A pre-segmentation root-scoped `manager.pid` is still read while it is the only spelling present, and is removed as the new record is written. A start that finds it already removed, by its exiting owner or by a concurrent start, continues. Both spellings present is reported as ambiguous rather than guessed. The manager writes the pid itself, whatever started it, and removes it on a clean stop only while it still names that process. A reader treats the record as a running manager only if the pid is alive **and** the process is a supervisor: a recycled pid belonging to something else is reported as a stale record, never signalled |\n| `delivery.<key>.pid` \xB7 `delivery.<key>.log` \xB7 `space.<key>/delivery.creds` | Delivery daemon pid and log for one space, and its scoped cred (auth mode). `cotal down` and the teardown of a foreground `cotal up` remove the cred once the daemon is confirmed stopped. Per-space and compatible with a pre-segmentation `delivery.pid` on the same terms as the manager row |\n| `web.pid` \xB7 `web.log` | Web dashboard pid + log |\n| `membership.json` \xB7 `membership-*.creds` | Membership feed state + its scoped creds |\n| `setup.log` | `cotal setup` log, one section appended per run. Each entry is one timestamped line: a control character or Unicode line separator in a path or error message is written as a `\\uXXXX` escape |\n\nA command that acts on the whole folder without being told a space reads one off these runtime\nrecords: `<key>` decodes back to the space name, and a space whose record is running wins over\nresidue from a stopped one. Two spaces running under one root is reported rather than arbitrated.\nThis is what lets `cotal status` and `cotal down` work in a folder whose mesh runs with\n`broker: { auth: false }`, where there is no `auth/account.<key>.json` to name the space.\nA record, or a `.cotal` listing, that exists but cannot be read is reported as that read error\nrather than read as absent.\n\n### Machine files\n\nCross-project machine state, so a `cotal spawn` from any directory can find a running mesh. Location:\n`~/.cotal` on POSIX, `%LOCALAPPDATA%\\Cotal` on Windows; overridable with `COTAL_HOME`.\n\n`COTAL_HOME` overrides **this tree only** (registry + current pointer + onboard marker). It is not a\nfull workstation sandbox. Broker launch policy, the JetStream store, pidfiles, and auth live under\nthe **project** `.cotal/` found by walking up from the cwd ([Project: `.cotal/`](#project-files)\nabove, including `broker-policy.json` on TLS meshes). A probe that sets `COTAL_HOME` alone and runs\n`cotal up --tls-cert \u2026` from a directory whose walked root is the operator home still writes those\nproject paths on the live machine.\n\n| Path | What it is |\n|---|---|\n| `meshes/space.<key>.json` | Registry of running meshes: one file per broker `cotal up` started (server URL, root path, mode, TLS-required client intent when recorded, attach bind host and live-session ceiling when the operator set them); `<key>` is the same case-safe hex encoding of the space name, and the record\'s own `space` field is authoritative |\n| `current-mesh` | Default space a bare `cotal spawn` joins (set by `cotal use`) |\n| `onboarded.json` | First-run marker (with `ONBOARD_VERSION`) that flips setup between first-run and status-card |\n| the Claude plugin marketplace | The installed `cotal-mesh` plugin assets |\n\n### Configuration files\n\nDistinct from `~/.cotal`. Location: `$XDG_CONFIG_HOME/cotal`, else `~/.config/cotal` on POSIX, or\n`%APPDATA%\\Cotal` on Windows.\n\n| Path | What it is |\n|---|---|\n| `config.json` | Operator-level connector config (the base layer above) |\n| `extensions/` | `cotal ext` install prefix: its own npm root (`node_modules`) plus an `extensions.json` provider/command-display cache. Built-in connectors install here too, seeded on first run |\n| `seed/` | Built-in-connector seeding state: the `ever-seeded` authority (+ durable backup), the init witness, the version stamp, the crash cursor, and `store/<version>/<name>` (the stable payloads `ext add --install-links` reifies each seeded connector from) |\n\nBoth `extensions/` and `seed/store/` are operator-global: shared by every space, project directory, and\ncheckout on the machine, and moved only by `$XDG_CONFIG_HOME` (a fresh project dir isolates `.cotal/`,\nnot these). `COTAL_HOME` does not relocate them. A CLI running from a source checkout (`pnpm cotal`,\n`tsx bin/cotal.ts`, `node bin/cotal.ts`, or a suite child of those, identified by a `bin/` package\nroot next to `implementations/` or `pnpm-workspace.yaml`) refuses to write, stamp, or\ngarbage-collect that store: the refusal names the store path, the generation it declined, and\n`COTAL_SKIP_CONNECTOR_SEED=1` as the way to run other commands from a checkout. An entry that\ncannot be proven as a released `cotal-ai` install is refused the same way. Isolating\n`$XDG_CONFIG_HOME` (on Windows, `%APPDATA%`) keeps a checkout off the installed config but does not\nlift the refusal by itself. A\nreleased install or an `npx` unpack still seeds as before. The in-tree seed smokes that must seed\nfrom a checkout-shaped `bin/` set `COTAL_ALLOW_CHECKOUT_SEED=1` against an isolated config; an\nopt-in write still records that checkout path in `seed/stamp.json` as `writtenBy`. The reconcile\nnames on stderr both the store payloads it writes and any old generation it removes, so a\nmachine-wide re-seed or cleanup is visible when it happens. Those lines are provenance output. When a\nstderr write fails, at once or after waiting in a full pipe, the line is printed on stdout with the\nerror and the reconcile still completes. If stdout fails too, the reconcile still completes and the\nrun exits 1 instead of 0. A line still waiting in a full stderr pipe when the run exits, as when the\nCLI exits on a closed stdout, is lost and also makes the run exit 1. Node does not say which stderr\nbytes are still waiting, so a line that had to wait and got through just before the exit also makes\nthe run exit 1 when later stderr output is still waiting. A run whose stderr is closed or redirected\naway at launch keeps the write and loses the line.\n\nFor how `cotal setup` populates the machine state and the plugin, and how the built-in connectors are\nseeded as removable extensions, see [setup internals](setup-internals.md).\n'
62541
62544
  },
62542
62545
  {
62543
62546
  "slug": "connect-claude",
@@ -62551,7 +62554,7 @@ function loadDocsBundle() {
62551
62554
  "title": "Connect Codex (beta)",
62552
62555
  "kind": "Guide (informative)",
62553
62556
  "summary": "OpenAI Codex joins a Cotal mesh as a lateral peer: the same cotal tool surface, the same message delivery and attention model as the other connectors, plus mid-turn steering (previously pi-only): a\u2026",
62554
- "body": "# Connect Codex (beta)\n\n> **Guide** (informative) \xB7 **For:** operators \xB7 **Prereqs:** [Quickstart](getting-started.md)\n\n[OpenAI Codex](https://developers.openai.com/codex/) joins a Cotal mesh as a lateral peer: the\nsame `cotal_*` tool surface, the same message delivery and attention model as the other\nconnectors, plus mid-turn steering (previously pi-only): a directed peer message arriving\nmid-turn is **steered into the running turn** instead of waiting for it to end.\n\n**Beta** means the everyday path (spawn into the real Codex TUI, coordinate, watch) works; the\nspawn options that are not wired **fail loud** rather than degrade: resuming a session\n(`--resume`) and tool-sharing (`connectors.codex.mcpServers`). See [Limits](#limits).\n\n## Install\n\nThe connector ships with the CLI as a seeded extension (`@cotal-ai/connector-codex`): no\nseparate install step and no Codex-side plugin. You only need an authenticated `codex` binary\non your PATH (a ChatGPT-plan login or an `OPENAI_API_KEY`). If an older install is missing it,\n`cotal ext seed --repair` (or `cotal ext add @cotal-ai/connector-codex`) brings it in.\n\n**Don't install the `cotal` plugin Codex offers you.** Searching Codex's plugin list for \"cotal\"\nturns up a plugin named `cotal`, from the `cotal-mesh` marketplace. That is the **Claude Code**\nadapter, which appears there only because Codex reads the same plugin-marketplace format; it is\nnot this connector and installing it does not connect Codex to a mesh. Codex needs nothing\ninstalled on its side: the connector drives it from the outside, over `codex app-server`.\n\n**Codex version.** The connector drives `codex app-server` over its experimental v2 surface.\nMinimum **codex-cli 0.145.0**; tested against 0.145.0 and 0.146.0. An older binary authenticates fine but has\nno `--listen`/`--ws-auth` listener, so the launch fails at startup rather than misbehaving quietly:\ncheck with `codex --version` and upgrade (`npm i -g @openai/codex`) if a launch reports that the\napp-server exited before it started listening. The surface is explicitly experimental upstream, so\na later Codex release may change it and need a connector update. That is a break to report, not a\nsupport range we can promise ahead of it.\n\n## Spawn it\n\nSame launch grammar as any agent (see [run-a-mesh.md](run-a-mesh.md)):\n\n```bash\ncotal spawn --agent codex # foreground in this terminal\ncotal spawn reviewer --agent codex -d # detached via the manager; watch with `cotal attach`\nCOTAL_DEFAULT_AGENT=codex cotal spawn # make codex the default harness\n```\n\nOr set `agent: codex` in a team [manifest](manifest.md). Persona, role, and model come from the\nagent file as for any connector ([agent-files.md](agent-files.md)).\n\n## Choose a model\n\n```bash\ncotal models --agent codex # ids + reasoning-effort variants, via app-server model/list\ncotal spawn --agent codex --model gpt-5.6-sol --variant high\n```\n\nThe **variant** is Codex's reasoning effort (`minimal` | `low` | `medium` | `high` | `xhigh`).\nLike the `codex` CLI itself, the connector does not validate model ids or efforts locally. An\nunknown value fails at request time, server-side.\n\nModel and variant are published on presence, which is where `cotal roster` and the web dashboard's\n`model \xB7 variant` badge read them from. The variant appears only when you asked for one (via\n`--variant` or `variant:` in the agent file): there is no way to read the effort back off a running\nthread, so an unset variant is shown as absent rather than guessed at.\n\n## How it binds\n\nCodex has no in-process plugin runtime and its MCP client cannot wake an idle session, so the\nconnector runs Codex's own client/server split: a small **host process** embeds the mesh\nendpoint and drives a `codex app-server` thread over JSON-RPC (the same protocol the Codex TUI\nruns on). The app-server runs as an authenticated loopback **listener** rather than a private\npipe, which is what lets Codex's own TUI attach to the very thread the mesh is driving.\n\n- **Wake and steer.** An inbound batch starts a real turn (`turn/start`). A DIRECTED message\n (DM, anycast, @mention) arriving mid-turn is injected into the live turn (`turn/steer`);\n ambient channel chatter waits for the turn boundary so it can't derail work in flight.\n- **Native tools, one endpoint.** The host serves the shared `cotal_*` tools itself, on a\n bearer-authenticated loopback MCP endpoint (the token is passed by env name, so it never appears\n in the process table; see [Limits](#limits) for what that token does and does not protect). The model calls them like any tool and they\n execute against the host's single mesh endpoint: no sidecar process, no second identity. The\n app-server is the MCP client, so the tools work the same on a turn a peer message started and\n on one **you** typed into the TUI.\n- **Ready means on the mesh.** The host announces `ready` and hands the terminal to Codex only\n after the app-server, MCP surface, and mesh endpoint are all live (including the initial\n presence publish). If the broker cannot be reached, startup fails within 15 seconds with the\n broker address and latest connection error; it never opens an offline-looking TUI.\n- **At-least-once delivery.** A turn's surfaced messages are acked (by exact id) when the turn\n completes, and also when the operator interrupts it (Escape in the attached TUI): that dismisses\n the batch rather than redelivering it. A failed turn retries with backoff, and an unknown terminal\n outcome (a missing or unrecognized status) leaves the batch un-acked with no retry of its own. If\n the Codex app-server itself dies, the host restarts it in place (same mesh identity, credential,\n and durable) and re-drives the un-acked batch into the new thread; a crash *loop* (more than 3 in\n 2 minutes) is fatal rather than an endless respawn. A retirement (the host shutting itself down)\n interrupts any live turn too, but that batch stays un-acked and redelivery to it is not promised:\n a later same-name spawn is a successor with its own delivery frontier. (The shared bounded-inbox\n overflow rule applies: under extreme bursts an evicted in-flight id cannot redeliver.)\n- **Isolated, never written.** Each agent gets a private `CODEX_HOME` (one hashed directory\n per space+name under `.cotal/codex/`, rooted at the manager's workspace): your `~/.codex`\n config.toml, hooks, and MCP servers never load into a managed agent, and Codex's per-project\n trust records never touch your real config. Your `auth.json` is symlinked in (re-linked each\n launch), so ChatGPT-plan token refreshes never fork. Without an `auth.json` (or an\n `OPENAI_API_KEY`) the launch fails loud at thread start. Keyring-stored credentials are not\n wired through the isolated home; use the file store or the env key for managed agents. That\n symlink is why managed Codex agents are **POSIX-only** today: on Windows without Developer\n Mode the link fails, and the launch fails loud rather than copying `auth.json` (a copy would\n fork the token and break plan refreshes).\n- **Autonomy defaults.** Spawned agents run `approval_policy=never`,\n `sandbox_mode=workspace-write`, and `sandbox_workspace_write={network_access=true}`.\n See [Sandbox autonomy](#sandbox-autonomy) for what each one means and how to\n change it.\n- **It really is Codex.** `cotal spawn --agent codex` drops you into the actual Codex TUI,\n attached to the thread the mesh drives (`codex resume --remote`). Mesh turns render as they\n happen, and anything you type is a real user turn on that same thread with the `cotal_*` tools\n still available. In the foreground that is your terminal; detached it is the manager's pty,\n which is what `cotal attach` streams and drives. With no terminal at all (piped output,\n CI, a smoke) the host stays headless and prints an activity feed instead: the same peer either\n way, only the UI differs.\n **Which mode you get** is decided by whether *stdout* is a terminal, and `COTAL_CODEX_TUI=1|0`\n overrides that check when it would guess wrong (a wrapper that redirects output, a CI run that\n wants deterministic text). The host refuses any value outside the on/off spellings in\n [Configuration](config.md#environment-variables) when it starts. It is read from the environment\n of **whichever process builds the launch**, so set it in the right place:\n - foreground `cotal spawn`: your own shell, per spawn;\n - detached (`-d`): the **manager's** environment, because the manager builds the launch. Set it\n where you start the manager (`COTAL_CODEX_TUI=0 cotal up`) and it applies to every codex agent\n that manager supervises. Exporting it in the shell that runs `cotal spawn -d` does nothing.\n\n A detached agent gets the manager's pty, which *is* a terminal, so the default there is the TUI,\n which is what `cotal attach` streams.\n Once the TUI paints, the terminal belongs to Codex, so the host's own diagnostics move to\n `host.log` inside the agent's private home\n (`<workspace>/.cotal/codex/<space>-<name>-<hash>/host.log`; the handoff line prints the exact\n path, and `ls -t .cotal/codex/*/host.log` finds it after the fact). Attached, a failure is also\n reported on the terminal; detached, that report goes to the pty, so the file is the durable copy.\n- **Presence from events.** working/idle/waiting are derived from the app-server event stream;\n approval requests relay an `approval` condition. A failed turn maps its native\n `codexErrorInfo` into the closed condition vocabulary and preserves that value in\n `condition.source`. Presence writes leave the host in the order the events arrived, so a\n turn that fails or asks for approval in the tick it started keeps its condition until the\n next turn starts. The model id is reported from the started thread.\n\n `contextWindowExceeded` maps to `context`; `sessionBudgetExceeded` to `budget`;\n `usageLimitExceeded` to `billing`; `rateLimitExceeded` to `rate_limit`;\n `serverOverloaded` to `overloaded`; `internalServerError` and `httpConnectionFailed` to\n `server`; `unauthorized` to `auth`; `badRequest`, `cyberPolicy`, and\n `misalignmentPolicyViolation` to `request`; and rollback, sandbox, `other`, or an unrecognized\n value to `failed`. An error marked `willRetry` maps to `retrying` while keeping its native source.\n\n`--opt k=v` launch options render as codex `-c k=v` config overrides on the app-server child\n(top-level keys, scalar values; write TOML inline-table text yourself for nested values). The\nconnector's own defaults and selectors ride the same rail and yield to yours, except\n`mcp_servers`, which is how the agent reaches the mesh: the whole namespace is refused loud (at\nspawn, not at launch) rather than silently overridden.\n\n## Event plane\n\nA spawned seat publishes a structured account of what it did: run\nboundaries per turn, assistant text, reasoning, and the tool calls the model makes through Codex's\nfunction-call and custom-tool interfaces, each with its start and its end. Tool arguments and\ntool results are not republished onto this channel. That covers the tools you watch a seat use,\n`shell` and `apply_patch` among them. The channel is\n`events.<owner>.<actor>`, named after the seat's principal, and the rules for it are the same on\nevery connector: see [connect-claude.md](connect-claude.md#event-plane) for the channel, the grant,\nand how to read it. The launcher sets `COTAL_EVENTS` by default; pass `--no-events` to opt out. Your\nown `codex` publishes nothing unless its environment arms the plane.\n\n```bash\ncotal spawn watcher --agent codex -d # event plane armed; read it with `cotal console`\n```\n\nEight things are specific to Codex and worth knowing before you read a stream:\n\n- **The thread's rollout file is the durable record.** The seat's\n rollout lives inside its own isolated `CODEX_HOME`, under\n `<workspace>/.cotal/codex/<space>-<name>-<hash>/sessions/<yyyy>/<mm>/<dd>/rollout-<stamp>-<thread>.jsonl`.\n Reading the file rather than the stream is what lets the seat resume a thread's stream where it\n stopped after its own process restarts, rather than reopening it from the top.\n- **A restarted app-server is a NEW thread, and its stream is a new one.** When the child dies and\n the seat brings up a replacement, Codex starts a fresh thread with a fresh rollout. The seat\n finishes the old one first, publishing what it had and closing any run left open, then begins\n publishing the new thread under its own write-ahead log. A reader sees one stream end and another\n begin, never one stream silently continuing under a different thread. If the new thread's file is\n slow to appear the order is the other way round: the seat spends its whole bounded look for the new\n file first, and the old stream ends when that look gives up, not at the moment of the restart. From\n the give-up on it publishes nothing until the new thread binds at a later turn boundary; it does not\n keep reporting the dead thread's activity in the meantime.\n- **The stream starts where the seat binds to the file.** `thread/start` writes nothing to disk; the\n file appears when the thread is primed. The seat binds to it then, and publishes from that point\n forward. If the file is slow to appear the seat says so in its log and looks again at each turn\n boundary, and whatever the thread wrote before the bind is not republished.\n- **Codex's built-in tools remain private to the host.** Web search, tool search and image generation\n record an end with no start, and nothing joins the two halves: the start-shaped record carries no\n call id and the end carries one. Rather than guess a pairing, the seat drops them, so those tool\n uses are absent from the stream while everything on the function-call path is present.\n- **Failed turns publish run errors.** Codex records a failure on\n the turn's own completion record, so a turn that hit a usage limit or an upstream error ends its\n run with `RUN_ERROR` carrying the fixed message `run failed` and no code. Neither Codex's error\n text nor its `codex_error_info` is published there: both are upstream values that can echo your\n prompt or tool output, and the events channel has a different read ACL.\n- **No user-authored text is published, ever.** Your prompts, the peer messages injected into the\n thread, and the developer instructions the persona supplies are all withheld. The events channel\n carries a different read ACL from the channel you typed into, so republishing your own words there\n would widen who can read them. Assistant text, reasoning and tool activity are unaffected.\n- **A broker outage can stop the plane.** Initial mesh absence still\n fails the host's readiness gate within 15 seconds, so it never opens an offline-looking TUI. Once\n ready, the plane publishes through the seat's reconnecting mesh endpoint, and an outage can stop\n its emitter for good. On a space that requires events the seat then stops with exit code 1. On\n any other space the seat keeps running, its log records `AG-UI emitter stopped`, and the thread\n publishes nothing more until an app-server restart brings up a new thread with its own plane.\n Tool arguments and tool results are not published. The stream also does not carry the\n session's own record of the user's words and the developer instructions. A bind announces\n where the stream starts and the emitter's setup then runs before its first read; what the\n thread appends inside that window is published rather than left behind the cursor, and a whole\n turn can sit in there. Nothing is sent twice. The boundary itself is written to the log as soon\n as the bind succeeds, so a host that dies before its first read still resumes from that boundary\n rather than from wherever the file ends by the time it comes back.\n\n The grant still does not decide who may READ a plane. A spawn through the manager gives a seat\n publish rights on its own event channel and nothing else, and a spawn whose grant names a\n different agent's event channel is refused at the door. That fence is the manager's, it reads\n the concrete form and leaves a pattern such as `events.<owner>.>` to ordinary ACL authority,\n and a foreground `cotal spawn` on your own machine grants whatever you name because it mints\n from your own signing material. [connect-claude.md](connect-claude.md#event-plane) spells all\n three out. Who may READ a plane is minted separately and out of band either way, with\n `cotal actor grant` on a user-auth mesh and `cotal mint --profile agent --allow-subscribe` on a\n static one. Who may read the plane is still that mint, not the spawn grant.\n- **Reasoning is published as its summary only.** Codex also stores an encrypted reasoning blob on\n every reasoning record; it is opaque, no reader can display it, and it is never put on the wire.\n\n## Sandbox autonomy\n\nA spawned Codex agent is woken by peer messages, which arrive when nobody is watching the\nterminal. The defaults follow from that, and all three are overridable per spawn with `--opt`.\n\n| Default | What it means |\n| --- | --- |\n| `approval_policy=\"never\"` | Never **ask** before running a command. Not \"refuse\": the agent runs its commands, it just does not stop to prompt. An interactive policy is refused loud rather than honored dishonestly, because a mesh-driven turn would block forever on a prompt nobody sees, and the alternative (auto-answering for you) nullifies the policy you asked for. |\n| `sandbox_mode=\"workspace-write\"` | Commands may read anywhere but write only inside the agent's workspace. This, not the prompt, is the part that is actually enforced; see below for the (real) exposure it leaves. |\n| `sandbox_workspace_write={network_access=true}` | Network **on** inside that sandbox. Codex's own default is off, which breaks installing a dependency, pushing a branch, or calling an API, with an error that reads like the task is impossible rather than the sandbox saying no. Applied only when the sandbox is actually `workspace-write`: tighten the mode and no network grant is emitted at all. |\n\nWhat the sandbox guarantees, stated literally: it **blocks out-of-workspace local filesystem\nwrites**. It does **not** block reads, exfiltration, or networked side effects.\n\nAll three of those are live with the defaults above, because a peer's message is a **remote input**\nthat can cause this agent to run commands. A confused or hostile peer can in principle get it to\nread a file elsewhere on your machine and send it; reach loopback or link-local services; or act\nthrough any credential it can read, which includes irreversible actions: a force-push, an API\ndelete, a deploy. Containing filesystem writes is therefore not the same as containing damage, and\nit should not be read that way. It is still worth keeping, because it is the one class this sandbox\ncan actually enforce.\n\nIf that exposure is wrong for a given agent, turn the network back off (below), tighten the mode,\nor run it under a separate OS user; the same point is repeated under [Limits](#limits) so it\nsurvives a skim. The spawn capability is the trust boundary for *who* may create an agent; the\nsandbox bounds one class of what it can then be talked into doing, not all of it.\n\nTune it per spawn:\n\n```bash\ncotal spawn --agent codex --opt sandbox_mode=read-only # tightest: no writes\ncotal spawn --agent codex --opt 'sandbox_workspace_write={network_access=false}' # contained, offline\ncotal spawn --agent codex --opt sandbox_mode=danger-full-access # no sandbox at all\n```\n\n`danger-full-access` is Codex's own name for it and means what it says: the agent may write\nanywhere your user account can. Codex documents that mode as intended only for environments that\nare already externally sandboxed (a container, a VM), not a workstation. On a laptop, prefer\ntightening the workspace over removing the sandbox.\n\n## Limits\n\n- **The sandbox blocks out-of-workspace filesystem writes, and only that.** It does not block\n reads, exfiltration, or networked side effects. With the default `workspace-write` + network on,\n a peer-driven turn can read anything your user account can (`~/.ssh`, `~/.aws`, `.env` files, the\n agent's own `auth.json`) and send it; reach loopback and link-local services; and act through any\n credential it can read, including irreversibly (a force-push, an API delete, a deploy). Only\n local writes outside the workspace are stopped, so this is not \"everything risky is reversible\"\n and not \"the only exposure is disclosure\". If that is wrong for a given agent, spawn it with\n `--opt 'sandbox_workspace_write={network_access=false}'` or `--opt sandbox_mode=read-only`, or\n run it as a separate OS user. See [Sandbox autonomy](#sandbox-autonomy).\n- **Not a boundary between agents on one machine.** The app-server listener and the tool\n endpoint are both loopback-bound and token-authenticated, which keeps out other OS users and\n anything off-box. It is not isolation between *managed agents*, which run as the same user and\n can therefore reach each other's tokens; a hostile agent on your workstation could drive\n another's Codex or speak as it on the mesh. Run mutually distrusted agents under separate OS\n users or separate machines.\n- **The TUI is local-only.** The app-server listener binds loopback and nothing else, so\n attaching Codex's UI to an agent on another machine needs your own SSH port-forward; there is\n no built-in remote attach. `cotal attach` (which streams the manager's pty) is the supported\n way to reach a detached agent.\n- **No session resume.** `cotal spawn --resume <id>` throws: a resumed codex thread comes up\n without its configured MCP servers, so the agent would be mute on the mesh.\n- **No tool-sharing.** `connectors.codex.mcpServers` is not implemented and throws if set.\n- **Experimental upstream surface.** `codex app-server` is labeled experimental by OpenAI (it\n is also what the Codex TUI itself runs on). The connector pins every protocol shape in one\n driver file and re-proves the contract with a gated live smoke (`COTAL_E2E_CODEX=1`).\n\n## See also\n\n- [Connectors](connectors.md): the feature matrix across all connectors\n- [Run a mesh](run-a-mesh.md) \xB7 [Define a team](define-a-team.md) \xB7 [Watch a mesh](watch-a-mesh.md)\n- [MCP tools](mcp-tools.md) \xB7 [Connect Claude Code](connect-claude.md) \xB7 [Connect OpenCode](connect-opencode.md)\n"
62557
+ "body": "# Connect Codex (beta)\n\n> **Guide** (informative) \xB7 **For:** operators \xB7 **Prereqs:** [Quickstart](getting-started.md)\n\n[OpenAI Codex](https://developers.openai.com/codex/) joins a Cotal mesh as a lateral peer: the\nsame `cotal_*` tool surface, the same message delivery and attention model as the other\nconnectors, plus mid-turn steering (previously pi-only): a directed peer message arriving\nmid-turn is **steered into the running turn** instead of waiting for it to end.\n\n**Beta** means the everyday path (spawn into the real Codex TUI, coordinate, watch) works; the\nspawn options that are not wired **fail loud** rather than degrade: resuming a session\n(`--resume`) and tool-sharing (`connectors.codex.mcpServers`). See [Limits](#limits).\n\n## Install\n\nThe connector ships with the CLI as a seeded extension (`@cotal-ai/connector-codex`): no\nseparate install step and no Codex-side plugin. You only need an authenticated `codex` binary\non your PATH (a ChatGPT-plan login or an `OPENAI_API_KEY`). If an older install is missing it,\n`cotal ext seed --repair` (or `cotal ext add @cotal-ai/connector-codex`) brings it in.\n\n**Don't install the `cotal` plugin Codex offers you.** Searching Codex's plugin list for \"cotal\"\nturns up a plugin named `cotal`, from the `cotal-mesh` marketplace. That is the **Claude Code**\nadapter, which appears there only because Codex reads the same plugin-marketplace format; it is\nnot this connector and installing it does not connect Codex to a mesh. Codex needs nothing\ninstalled on its side: the connector drives it from the outside, over `codex app-server`.\n\n**Codex version.** The connector drives `codex app-server` over its experimental v2 surface.\nMinimum **codex-cli 0.145.0**; tested against 0.145.0 and 0.146.0. An older binary authenticates fine but has\nno `--listen`/`--ws-auth` listener, so the launch fails at startup rather than misbehaving quietly:\ncheck with `codex --version` and upgrade (`npm i -g @openai/codex`) if a launch reports that the\napp-server exited before it started listening. The surface is explicitly experimental upstream, so\na later Codex release may change it and need a connector update. That is a break to report, not a\nsupport range we can promise ahead of it.\n\n## Spawn it\n\nSame launch grammar as any agent (see [run-a-mesh.md](run-a-mesh.md)):\n\n```bash\ncotal spawn --agent codex # foreground in this terminal\ncotal spawn reviewer --agent codex -d # detached via the manager; watch with `cotal attach`\nCOTAL_DEFAULT_AGENT=codex cotal spawn # make codex the default harness\n```\n\nOr set `agent: codex` in a team [manifest](manifest.md). Persona, role, and model come from the\nagent file as for any connector ([agent-files.md](agent-files.md)).\n\n## Choose a model\n\n```bash\ncotal models --agent codex # ids + reasoning-effort variants, via app-server model/list\ncotal spawn --agent codex --model gpt-5.6-sol --variant high\n```\n\nThe **variant** is Codex's reasoning effort (`minimal` | `low` | `medium` | `high` | `xhigh`).\nLike the `codex` CLI itself, the connector does not validate model ids or efforts locally. An\nunknown value fails at request time, server-side.\n\nModel and variant are published on presence, which is where `cotal roster` and the web dashboard's\n`model \xB7 variant` badge read them from. The variant appears only when you asked for one (via\n`--variant` or `variant:` in the agent file): there is no way to read the effort back off a running\nthread, so an unset variant is shown as absent rather than guessed at.\n\n## How it binds\n\nCodex has no in-process plugin runtime and its MCP client cannot wake an idle session, so the\nconnector runs Codex's own client/server split: a small **host process** embeds the mesh\nendpoint and drives a `codex app-server` thread over JSON-RPC (the same protocol the Codex TUI\nruns on). The app-server runs as an authenticated loopback **listener** rather than a private\npipe, which is what lets Codex's own TUI attach to the very thread the mesh is driving.\n\n- **Wake and steer.** An inbound batch starts a real turn (`turn/start`). A DIRECTED message\n (DM, anycast, @mention) arriving mid-turn is injected into the live turn (`turn/steer`);\n ambient channel chatter waits for the turn boundary so it can't derail work in flight.\n- **Native tools, one endpoint.** The host serves the shared `cotal_*` tools itself, on a\n bearer-authenticated loopback MCP endpoint (the token is passed by env name, so it never appears\n in the process table; see [Limits](#limits) for what that token does and does not protect). The model calls them like any tool and they\n execute against the host's single mesh endpoint: no sidecar process, no second identity. The\n app-server is the MCP client, so the tools work the same on a turn a peer message started and\n on one **you** typed into the TUI.\n- **Ready means on the mesh.** The host announces `ready` and hands the terminal to Codex only\n after the app-server, MCP surface, and mesh endpoint are all live (including the initial\n presence publish). If the broker cannot be reached, startup fails within 15 seconds with the\n broker address and latest connection error; it never opens an offline-looking TUI.\n- **At-least-once delivery.** A turn's surfaced messages are acked (by exact id) when the turn\n completes, and also when the operator interrupts it (Escape in the attached TUI): that dismisses\n the batch rather than redelivering it. A failed turn retries with backoff, and an unknown terminal\n outcome (a missing or unrecognized status) leaves the batch un-acked with no retry of its own. If\n the Codex app-server itself dies, the host restarts it in place (same mesh identity, credential,\n and durable) and re-drives the un-acked batch into the new thread; a crash *loop* (more than 3 in\n 2 minutes) is fatal rather than an endless respawn. A retirement (the host shutting itself down)\n interrupts any live turn too, but that batch stays un-acked and redelivery to it is not promised:\n a later same-name spawn is a successor with its own delivery frontier. (The shared bounded-inbox\n overflow rule applies: under extreme bursts an evicted in-flight id cannot redeliver.)\n- **Isolated, never written.** Each agent gets a private `CODEX_HOME` (one hashed directory\n per space+name under `.cotal/codex/`, rooted at the manager's workspace): your `~/.codex`\n config.toml, hooks, and MCP servers never load into a managed agent, and Codex's per-project\n trust records never touch your real config. Your `auth.json` is symlinked in (re-linked each\n launch), so ChatGPT-plan token refreshes never fork. Without an `auth.json` (or an\n `OPENAI_API_KEY`) the launch fails loud at thread start. Keyring-stored credentials are not\n wired through the isolated home; use the file store or the env key for managed agents. That\n symlink is why managed Codex agents are **POSIX-only** today: on Windows without Developer\n Mode the link fails, and the launch fails loud rather than copying `auth.json` (a copy would\n fork the token and break plan refreshes).\n- **Autonomy defaults.** Spawned agents run `approval_policy=never`,\n `sandbox_mode=workspace-write`, and `sandbox_workspace_write={network_access=true}`.\n See [Sandbox autonomy](#sandbox-autonomy) for what each one means and how to\n change it.\n- **It really is Codex.** `cotal spawn --agent codex` drops you into the actual Codex TUI,\n attached to the thread the mesh drives (`codex resume --remote`). Mesh turns render as they\n happen, and anything you type is a real user turn on that same thread with the `cotal_*` tools\n still available. In the foreground that is your terminal; detached it is the manager's pty,\n which is what `cotal attach` streams and drives. With no terminal at all (piped output,\n CI, a smoke) the host stays headless and prints an activity feed instead: the same peer either\n way, only the UI differs.\n **Which mode you get** is decided by whether *stdout* is a terminal, and `COTAL_CODEX_TUI=1|0`\n overrides that check when it would guess wrong (a wrapper that redirects output, a CI run that\n wants deterministic text). The host refuses any value outside the on/off spellings in\n [Configuration](config.md#environment-variables) when it starts. It is read from the environment\n of **whichever process builds the launch**, so set it in the right place:\n - foreground `cotal spawn`: your own shell, per spawn;\n - detached (`-d`): the **manager's** environment, because the manager builds the launch. Set it\n where you start the manager (`COTAL_CODEX_TUI=0 cotal up`) and it applies to every codex agent\n that manager supervises. Exporting it in the shell that runs `cotal spawn -d` does nothing.\n\n A detached agent gets the manager's pty, which *is* a terminal, so the default there is the TUI,\n which is what `cotal attach` streams.\n Once the TUI paints, the terminal belongs to Codex, so the host's own diagnostics move to\n `host.log` inside the agent's private home\n (`<workspace>/.cotal/codex/<space>-<name>-<hash>/host.log`; the handoff line prints the exact\n path, and `ls -t .cotal/codex/*/host.log` finds it after the fact). Attached, a failure is also\n reported on the terminal; detached, that report goes to the pty, so the file is the durable copy.\n- **Presence from events.** working/idle/waiting are derived from the app-server event stream;\n approval requests relay an `approval` condition. A failed turn maps its native\n `codexErrorInfo` into the closed condition vocabulary and preserves that value in\n `condition.source`. Presence writes leave the host in the order the events arrived, so a\n turn that fails or asks for approval in the tick it started keeps its condition until the\n next turn starts. The model id is reported from the started thread.\n\n `contextWindowExceeded` maps to `context`; `sessionBudgetExceeded` to `budget`;\n `usageLimitExceeded` to `billing`; `rateLimitExceeded` to `rate_limit`;\n `serverOverloaded` to `overloaded`; `internalServerError` and `httpConnectionFailed` to\n `server`; `unauthorized` to `auth`; `badRequest`, `cyberPolicy`, and\n `misalignmentPolicyViolation` to `request`; and rollback, sandbox, `other`, or an unrecognized\n value to `failed`. An error marked `willRetry` maps to `retrying` while keeping its native source.\n\n`--opt k=v` launch options render as codex `-c k=v` config overrides on the app-server child\n(top-level keys, scalar values; write TOML inline-table text yourself for nested values). The\nconnector's own defaults and selectors ride the same rail and yield to yours, except\n`mcp_servers`, which is how the agent reaches the mesh: the whole namespace is refused loud (at\nspawn, not at launch) rather than silently overridden.\n\n## Event plane\n\nA spawned seat publishes a structured account of what it did: run\nboundaries per turn, assistant text, reasoning, and the tool calls the model makes through Codex's\nfunction-call and custom-tool interfaces, each with its start and its end. Tool arguments and\ntool results are not republished onto this channel. That covers the tools you watch a seat use,\n`shell` and `apply_patch` among them. The channel is\n`events.<owner>.<actor>`, named after the seat's principal, and the rules for it are the same on\nevery connector: see [connect-claude.md](connect-claude.md#event-plane) for the channel, the grant,\nand how to read it. The launcher sets `COTAL_EVENTS` by default; pass `--no-events` to opt out on an\nunrestricted space. A registration that requires events refuses `--no-events`, and its launch\nalways carries the flag and the workspace root the event log lives under. A hand-driven user-mode\nlaunch with `COTAL_EVENTS_REQUIRED=1` arms the plane without that flag.\nYour own `codex` publishes nothing unless its environment arms the plane.\n\n```bash\ncotal spawn watcher --agent codex -d # event plane armed; read it with `cotal console`\n```\n\nEight things are specific to Codex and worth knowing before you read a stream:\n\n- **The thread's rollout file is the durable record.** The seat's\n rollout lives inside its own isolated `CODEX_HOME`, under\n `<workspace>/.cotal/codex/<space>-<name>-<hash>/sessions/<yyyy>/<mm>/<dd>/rollout-<stamp>-<thread>.jsonl`.\n Reading the file rather than the stream is what lets the seat resume a thread's stream where it\n stopped after its own process restarts, rather than reopening it from the top.\n- **A restarted app-server is a NEW thread, and its stream is a new one.** When the child dies and\n the seat brings up a replacement, Codex starts a fresh thread with a fresh rollout. The seat\n finishes the old one first, publishing what it had and closing any run left open, then begins\n publishing the new thread under its own write-ahead log. A reader sees one stream end and another\n begin, never one stream silently continuing under a different thread. If the new thread's file is\n slow to appear the order is the other way round: the seat spends its whole bounded look for the new\n file first, and the old stream ends when that look gives up, not at the moment of the restart. From\n the give-up on it publishes nothing until the new thread binds at a later turn boundary; it does not\n keep reporting the dead thread's activity in the meantime.\n- **The stream starts where the seat binds to the file.** `thread/start` writes nothing to disk; the\n file appears when the thread is primed. The seat binds to it then, and publishes from that point\n forward. If the file is slow to appear the seat says so in its log and looks again at each turn\n boundary, and whatever the thread wrote before the bind is not republished.\n- **Codex's built-in tools remain private to the host.** Web search, tool search and image generation\n record an end with no start, and nothing joins the two halves: the start-shaped record carries no\n call id and the end carries one. Rather than guess a pairing, the seat drops them, so those tool\n uses are absent from the stream while everything on the function-call path is present.\n- **Failed turns publish run errors.** Codex records a failure on\n the turn's own completion record, so a turn that hit a usage limit or an upstream error ends its\n run with `RUN_ERROR` carrying the fixed message `run failed` and no code. Neither Codex's error\n text nor its `codex_error_info` is published there: both are upstream values that can echo your\n prompt or tool output, and the events channel has a different read ACL.\n- **No user-authored text is published, ever.** Your prompts, the peer messages injected into the\n thread, and the developer instructions the persona supplies are all withheld. The events channel\n carries a different read ACL from the channel you typed into, so republishing your own words there\n would widen who can read them. Assistant text, reasoning and tool activity are unaffected.\n- **A broker outage can stop the plane.** Initial mesh absence still\n fails the host's readiness gate within 15 seconds, so it never opens an offline-looking TUI. Once\n ready, the plane publishes through the seat's reconnecting mesh endpoint, and an outage can stop\n its emitter for good. On a space that requires events the seat then stops with exit code 1. On\n any other space the seat keeps running, its log records `AG-UI emitter stopped`, and the thread\n publishes nothing more until an app-server restart brings up a new thread with its own plane.\n Tool arguments and tool results are not published. The stream also does not carry the\n session's own record of the user's words and the developer instructions. A bind announces\n where the stream starts and the emitter's setup then runs before its first read; what the\n thread appends inside that window is published rather than left behind the cursor, and a whole\n turn can sit in there. Nothing is sent twice. The boundary itself is written to the log as soon\n as the bind succeeds, so a host that dies before its first read still resumes from that boundary\n rather than from wherever the file ends by the time it comes back.\n\n The grant still does not decide who may READ a plane. A spawn through the manager gives a seat\n publish rights on its own event channel and nothing else, and a spawn whose grant names a\n different agent's event channel is refused at the door. That fence is the manager's, it reads\n the concrete form and leaves a pattern such as `events.<owner>.>` to ordinary ACL authority,\n and a foreground `cotal spawn` on your own machine grants whatever you name because it mints\n from your own signing material. [connect-claude.md](connect-claude.md#event-plane) spells all\n three out. Who may READ a plane is minted separately and out of band either way, with\n `cotal actor grant` on a user-auth mesh and `cotal mint --profile agent --allow-subscribe` on a\n static one. Who may read the plane is still that mint, not the spawn grant.\n- **Reasoning is published as its summary only.** Codex also stores an encrypted reasoning blob on\n every reasoning record; it is opaque, no reader can display it, and it is never put on the wire.\n\n## Sandbox autonomy\n\nA spawned Codex agent is woken by peer messages, which arrive when nobody is watching the\nterminal. The defaults follow from that, and all three are overridable per spawn with `--opt`.\n\n| Default | What it means |\n| --- | --- |\n| `approval_policy=\"never\"` | Never **ask** before running a command. Not \"refuse\": the agent runs its commands, it just does not stop to prompt. An interactive policy is refused loud rather than honored dishonestly, because a mesh-driven turn would block forever on a prompt nobody sees, and the alternative (auto-answering for you) nullifies the policy you asked for. |\n| `sandbox_mode=\"workspace-write\"` | Commands may read anywhere but write only inside the agent's workspace. This, not the prompt, is the part that is actually enforced; see below for the (real) exposure it leaves. |\n| `sandbox_workspace_write={network_access=true}` | Network **on** inside that sandbox. Codex's own default is off, which breaks installing a dependency, pushing a branch, or calling an API, with an error that reads like the task is impossible rather than the sandbox saying no. Applied only when the sandbox is actually `workspace-write`: tighten the mode and no network grant is emitted at all. |\n\nWhat the sandbox guarantees, stated literally: it **blocks out-of-workspace local filesystem\nwrites**. It does **not** block reads, exfiltration, or networked side effects.\n\nAll three of those are live with the defaults above, because a peer's message is a **remote input**\nthat can cause this agent to run commands. A confused or hostile peer can in principle get it to\nread a file elsewhere on your machine and send it; reach loopback or link-local services; or act\nthrough any credential it can read, which includes irreversible actions: a force-push, an API\ndelete, a deploy. Containing filesystem writes is therefore not the same as containing damage, and\nit should not be read that way. It is still worth keeping, because it is the one class this sandbox\ncan actually enforce.\n\nIf that exposure is wrong for a given agent, turn the network back off (below), tighten the mode,\nor run it under a separate OS user; the same point is repeated under [Limits](#limits) so it\nsurvives a skim. The spawn capability is the trust boundary for *who* may create an agent; the\nsandbox bounds one class of what it can then be talked into doing, not all of it.\n\nTune it per spawn:\n\n```bash\ncotal spawn --agent codex --opt sandbox_mode=read-only # tightest: no writes\ncotal spawn --agent codex --opt 'sandbox_workspace_write={network_access=false}' # contained, offline\ncotal spawn --agent codex --opt sandbox_mode=danger-full-access # no sandbox at all\n```\n\n`danger-full-access` is Codex's own name for it and means what it says: the agent may write\nanywhere your user account can. Codex documents that mode as intended only for environments that\nare already externally sandboxed (a container, a VM), not a workstation. On a laptop, prefer\ntightening the workspace over removing the sandbox.\n\n## Limits\n\n- **The sandbox blocks out-of-workspace filesystem writes, and only that.** It does not block\n reads, exfiltration, or networked side effects. With the default `workspace-write` + network on,\n a peer-driven turn can read anything your user account can (`~/.ssh`, `~/.aws`, `.env` files, the\n agent's own `auth.json`) and send it; reach loopback and link-local services; and act through any\n credential it can read, including irreversibly (a force-push, an API delete, a deploy). Only\n local writes outside the workspace are stopped, so this is not \"everything risky is reversible\"\n and not \"the only exposure is disclosure\". If that is wrong for a given agent, spawn it with\n `--opt 'sandbox_workspace_write={network_access=false}'` or `--opt sandbox_mode=read-only`, or\n run it as a separate OS user. See [Sandbox autonomy](#sandbox-autonomy).\n- **Not a boundary between agents on one machine.** The app-server listener and the tool\n endpoint are both loopback-bound and token-authenticated, which keeps out other OS users and\n anything off-box. It is not isolation between *managed agents*, which run as the same user and\n can therefore reach each other's tokens; a hostile agent on your workstation could drive\n another's Codex or speak as it on the mesh. Run mutually distrusted agents under separate OS\n users or separate machines.\n- **The TUI is local-only.** The app-server listener binds loopback and nothing else, so\n attaching Codex's UI to an agent on another machine needs your own SSH port-forward; there is\n no built-in remote attach. `cotal attach` (which streams the manager's pty) is the supported\n way to reach a detached agent.\n- **No session resume.** `cotal spawn --resume <id>` throws: a resumed codex thread comes up\n without its configured MCP servers, so the agent would be mute on the mesh.\n- **No tool-sharing.** `connectors.codex.mcpServers` is not implemented and throws if set.\n- **Experimental upstream surface.** `codex app-server` is labeled experimental by OpenAI (it\n is also what the Codex TUI itself runs on). The connector pins every protocol shape in one\n driver file and re-proves the contract with a gated live smoke (`COTAL_E2E_CODEX=1`).\n\n## See also\n\n- [Connectors](connectors.md): the feature matrix across all connectors\n- [Run a mesh](run-a-mesh.md) \xB7 [Define a team](define-a-team.md) \xB7 [Watch a mesh](watch-a-mesh.md)\n- [MCP tools](mcp-tools.md) \xB7 [Connect Claude Code](connect-claude.md) \xB7 [Connect OpenCode](connect-opencode.md)\n"
62555
62558
  },
62556
62559
  {
62557
62560
  "slug": "connect-hermes",
@@ -62565,7 +62568,7 @@ function loadDocsBundle() {
62565
62568
  "title": "Connect Jcode (beta)",
62566
62569
  "kind": "Guide (informative)",
62567
62570
  "summary": "Jcode joins a Cotal mesh as a lateral peer.",
62568
- "body": "# Connect Jcode (beta)\n\n> **Guide** (informative) \xB7 **For:** operators \xB7 **Prereqs:** [Quickstart](getting-started.md)\n\n[Jcode](https://github.com/1jehuang/jcode) joins a Cotal mesh as a lateral peer. The connector\ncreates one private Jcode Harness API instance per seat, one Jcode session inside it, and exposes\nthe normal `cotal_*` tool surface through Jcode's documented stdio MCP configuration.\n\n**Beta** means the supported path is deliberately narrow: a fresh private session, a fork of one\nof your own sessions (`--resume`), prompt injection, presence, managed start/stop, requested\nreasoning effort, and an attached TUI work. Features that do not preserve that private session's\nmesh surface fail loud: exact-session continuation, `--share-tools`, and connector `--opt` values\nare not supported.\n\n## Install\n\nThe connector is seeded with the Cotal CLI. Jcode's released Harness API bridge is a Unix-socket\nsurface, so Windows is not supported. Managed seats run on Linux, macOS, and the BSDs.\nInstall Jcode 0.78.1 or later from its GitHub release and make the binary available as `jcode` on `PATH`:\n\n```bash\njcode version --json\ncotal spawn --agent jcode\n```\n\nIf an older Cotal installation is missing the connector, run `cotal ext seed --repair` (or\n`cotal ext add @cotal-ai/connector-jcode`). This connector intentionally uses the released\nbinary's `api-bridge` command; it does not require a Rust checkout.\n\n## Spawn it\n\n```bash\ncotal spawn --agent jcode\ncotal spawn reviewer --agent jcode -d\ncotal spawn --agent jcode --model gpt-5.6-sol --prompt \"Review the current change.\"\nCOTAL_DEFAULT_AGENT=jcode cotal spawn\n```\n\nA detached seat is managed normally: `cotal ps`, `cotal attach`, and `cotal stop` control the\nsame process the connector starts. In a terminal, Jcode opens on the managed session. With piped\noutput it stays headless; set `COTAL_JCODE_TUI=1` or `COTAL_JCODE_TUI=0` in the environment of the\nprocess building the launch to override that choice. For a detached spawn, that is the manager's\nenvironment. The connector refuses any value outside the on/off spellings in\n[Configuration](config.md#environment-variables) when it starts.\n\n## How it binds\n\nJcode's stable integration surface is the **Harness API**: protocol-v1 NDJSON over a Unix socket.\nThe connector launches a **private instance** with `@1jehuang/jcode-sdk`'s `launchInstance()` and\nattaches only to that instance's own socket:\n\n- `launchInstance()` starts a private `JCODE_HOME`, runtime directory, daemon, and `api-bridge`;\n the connector holds the process handle first-hand and closes that instance with the Cotal seat.\n This gives each managed Cotal peer one owned session and prevents it from seeing or changing\n the operator's live Jcode sessions.\n- Attaching to an **operator-run** `jcode api-bridge` shares the operator's live session\n inventory. That is appropriate for a dashboard or editor integration, but not a managed Cotal\n seat: stop, prompt injection, and session selection could act on the operator's work. The\n connector never attaches to an operator bridge.\n- A managed seat **never updates its own binary**. Jcode's background updater restarts the\n process tree when it lands a release; that restart drops the seat's TUI, which is the only\n connection the Jcode server counts as a client, and nothing re-attaches, so the server's idle\n reaper takes the seat down five minutes later in the middle of a turn. The seat's version is\n whatever is on `PATH` when you spawn it, and it stays that version for the seat's life. Update\n deliberately, between seats, not under a running agent.\n\nOn a graceful stop **and** on a startup failure, the connector proves the private daemon tree is\nactually gone rather than trusting the SDK's registry-keyed stop (which is a silent no-op when the\n`servers.json` socket path does not match verbatim): it reads the PIDs the private home itself\nrecords, sends a bounded SIGTERM, escalates survivors to an exact-PID SIGKILL, and reports a\nfailed stop instead of a clean one if any recorded process survives. It never signals by name, so\nteardown can only ever reach the seat's own tree.\n\nA seat that dies without that teardown, from a manager restart or a kill past the grace window,\nleaves its Jcode server running. The server has a process group of its own and carries no\n`COTAL_NAME`, so a name-keyed reap does not reach it, and it holds the seat's runtime directory\nuntil its own five-minute idle timer expires. Each launch records its identity nonce and its host\nprocess in the private home, and the seat's next launch stops the tree that record names. The\nrecorded host is the gate: while it is still alive the seat is still serving, so nothing is\nsignalled and the second launch meets Jcode's own runtime-directory lock instead.\n\nThe private Jcode home lives under `<manager-workspace>/.cotal/jcode/`. It is unique per\nspace/name and is owner-only. Jcode's own credential inheritance is used for the private instance,\nso provider logins work without copying its transcript/config tree into the seat. The spawned\nJcode process does not inherit `COTAL_*` values or the Cotal launch-material pointer.\n\nBecause the home is keyed by space and name, a seat respawned under the same space, name, and\nmanager workspace lands in the same home, and the connector automatically continues the\nnon-archived Jcode session there that was recorded for the seat's working directory and holds the\nlargest transcript, since that is the session carrying the memory a restart would otherwise throw\naway. A seat spawned under a fresh name keys a different home and starts with an\nempty transcript, so keep the same name when you want a replacement seat to continue where the\nprevious one stopped. This automatic continuation is a relaunch of the seat's own private session;\nit is separate from `--resume`, which forks an outside session into the seat (below). The short\nsocket alias the connector derives from that home is reclaimed at every launch, so a name a stopped\nseat used stays launchable.\n\n`cotal spawn --resume <id>` forks session `<id>` from your own Jcode home (`JCODE_HOME`, or the\ndefault Jcode home) into the seat's private home before the seat's instance starts. The Harness API\nhas no fork call, so the connector does what Jcode's own split does: it writes a new session whose\nparent is the source and which carries the source's messages, compaction state, system prompt, and\nmodel. The seat gets a new session id and its briefing as the first turn after that history. The\nsource files are only read, so the source transcript is never appended to. A session id with no\nreadable transcript, or one whose snapshot or journal holds fields Jcode cannot load, is refused\nbefore the seat launches; every carried message, content block, and compaction state is checked\nagainst Jcode's own schema, and the refusal names the field. Jcode stores its counts as u64 and\nreads one only as a plain decimal integer, so a count above what a JavaScript number holds is copied\nbyte for byte, and one above the u64 range or spelled with a fraction or an exponent (`1e3`, `1.0`)\nis refused. The source may be live: the connector\nreads it until two reads agree and every journal line is newer than the snapshot, so a Jcode\ncheckpoint caught mid-way is neither lost nor applied twice, and a session that keeps changing is\nrefused with a request to retry. A seat relaunched under the same name continues its\nfork rather than forking again, without reading the source, which may since have been deleted. The\nseat's briefing is recorded separately from the fork, so a first launch that fails after forking\nstill briefs the seat on its next launch. The seat records the source session id, its title, and a\nSHA-256 of the snapshot and journal it read, prints them when it forks, and the manager reads that\nrecord into the seat's resume document, so `cotal ps --wide` shows them. The manager keeps a title\nof at most 1024 characters, so a source with a longer title is refused before the seat launches.\n\nConnector diagnostics are written both to the spawning terminal and to an owner-only\n`<private-home>/logs/connector-<timestamp>-<pid>.log`, so a failed launch remains inspectable after\nthe manager's launch error scrolls away. Public startup failures stay scrubbed to allow-listed\ncodes rather than arbitrary Harness API messages.\n\nCredential mirroring is mandatory for managed Jcode seats: each launch atomically refreshes the\nallowlisted Jcode, provider-config, and external-login destinations, and removes a destination when\nits source login was removed. Cleanup addresses only that explicit inventory; transcripts, MCP\nconfiguration, logs, and other private-home state are untouched. Copy, mkdir, and removal walk the\nparents with `O_NOFOLLOW`, then publish, create, or unlink the leaf through the pinned parent rather\nthan through a path the kernel re-walks. Replacing a walked directory with a symlink cannot write,\ncreate, or delete a namesake outside the private home.\n\nTwo mechanisms provide that pin. On Linux the leaf is named `/dev/fd/<fd>/<name>`, the openat and\nunlinkat equivalent Node does not expose, after `/dev/fd/<fd>/.` is proven to traverse. macOS mounts\n`/dev/fd` but has no subpath namespace under a descriptor, so there the connector pins the parent as\nthe process working directory instead: a single-component name resolves from that directory's inode\nand no ancestor is walked again. Entering by path is verified rather than trusted, because `chdir`\ntakes a path: the entered directory's inode must equal the inode of the descriptor opened a moment\nearlier, and a mismatch is refused by name. The working directory is restored on every exit,\nincluding the refusing ones. If neither pin is available, the connector throws a named error and\nmirrors nothing. Once a pin holds, `ENOENT` on a child means the mirror path is absent.\n\nThere is no credential-free opt-out today because the private instance must reproduce the operator's\ncurrent provider-login state rather than silently start with stale or partial authorization.\n\nIf a provider failure closes the private Harness API connection during a mesh-driven turn, the\nconnector leaves that turn's inbox batch unacknowledged and opens one bounded recovery window for a\nprivate replacement connection to the same session. The window lasts 60 seconds from the close, and\nstopping the broken private tree counts against it. A transient launch or attach failure retries\ninside that window, so a loaded host gets the same result as a fast one without creating an\nunbounded connector relaunch loop. The seat reports `waiting` while it reconnects, then redrives\nthat unacknowledged batch only after the session attaches. Each failed replacement must be proven\nstopped before another launch. A permanent Harness refusal, including an invalid request, missing\nsession, protocol mismatch, missing binary, or socket permission denial, ends the seat immediately;\nanother launch cannot change it. An unprovable teardown, the recovery window expiring, or a second\ndisconnect after a successful replacement also ends the seat. When a turn failed on a Harness error\nand no turn has succeeded since, whether the host or the TUI owned it, the connector line that ends\nthe seat this way also names that error as `last turn error: <message>`, so the manager's\n`seat reaped:` line carries it without a read of the seat's private log. The reap line keeps only the\nfirst 240 characters, so that field comes before the error of a failed recovery, which then reads\n`recovery error: <message>`. An unrecognized Harness SDK error\ncode remains transient by default and retries inside the same bounded window; new permanent codes\nmust be added to the explicit classifier and its exact-count regression.\n\nJcode currently supports **stdio** MCP servers. The connector writes only its own `cotal` entry to\nthe private `JCODE_HOME/mcp.json`; it starts a stdio MCP bridge for that entry and relays its calls\nto the host's one `MeshAgent`. The Jcode/MCP child receives a per-launch relay capability, but not\nthe Cotal broker credential or its launch-material pointer. Jcode also overlays project\n`.jcode/mcp.json`, `.mcp.json`, and `.claude/mcp.json`; a managed launch **refuses** a workspace\ncontaining any of those files, because one could replace the `cotal` bridge or add tools that were\nnot explicitly shared. Operator MCP configuration is isolated in the private home and project MCP\nconfiguration is not supported yet.\n\nBefore the seat joins the mesh, the host runs a mandatory Jcode turn that calls\n`cotal_orientation`. Jcode loads MCP tools asynchronously; its first turn can use the pre-MCP tool\nsnapshot immediately before Jcode rebuilds that snapshot. The host repeats the identical proof once\nin that case. A second absence fails the launch, so a bridge that never comes up remains a loud\nfailure rather than an agent that is present but mute. The persona is already in that transcript as\na no-reply message; the spawn `--prompt` is not submitted until after join. While the proof is in\nflight the connector log names `pre-join readiness` and the bound. That in-flight line only means\nstartup reached the gate; it is not a hang, a missing prompt, or a provider refusal by itself.\nAfter the turn, a second line names the outcome and is what separates those cases:\n`orientation proved; joining with no spawn --prompt` or `joining, then submitting the spawn\n--prompt` when the proof passed; `provider refusal` when the provider rejected the turn; `timeout`\nwhen the bound fired. A genuine hang that never returns and never hits the bound has no outcome\nline. A one-line log with no route line is not that signal. The proof itself is bounded to the\nsame three minutes the connector declares to the manager (`readinessTimeoutMs` is the exported\n`JCODE_READINESS_TIMEOUT_MS`). Tests may shorten the host bound through\n`COTAL_JCODE_READINESS_TIMEOUT_MS`; that override is not an operator setting and does not change\nthe window the connector declares to the manager. The bound is a call-observation deadline: the\nhost proves readiness on the orientation `tool_done` event as it arrives, without waiting for\n`turn_done`. If that call never arrives inside the window the host exits `readiness_timeout` and\nnever joins, rather than working invisibly. That teardown does not wait for the in-flight turn:\nit kills the private Jcode tree and discards whatever that turn had generated. Nothing from it\nis recoverable; inspect the seat connector log for the timeout outcome, then spawn again. The\ntimeout line names whether the orientation call was observed. A seat that already made the call\njoins even if that proof turn is still open; the host does not destroy a functional session\nsolely because `turn_done` has not arrived. The manager's wait can still report `uncertain` when\njoin itself is slow after a passing proof; that is not a cleanup verdict, and it is not the same\nas a host `readiness_timeout`. Use `cotal attach <name>` or `cotal ps` to inspect an `uncertain`\nlaunch. The\nhost then waits for the mesh connection and presence bind to complete before it delivers a notice\nthat the bootstrap orientation predates the join and that a new orientation is live context. During\na broker outage, it stays waiting and sends no connected notice.\n\nA seat launched with a spawn `--prompt` gets that notice as a no-reply append; the prompt is still\nthe seat's first driven turn. A seat launched with no spawn `--prompt` has nothing else to schedule\na turn after join, so the notice is delivered as the seat's first driven turn instead of a no-reply\nappend: the same dispatch boundary and one-shot rule the startup prompt uses, so the seat's final\nstartup state is never an unread append.\n\nA refused post-join notice sent as a no-reply append is logged without ending the joined session.\nWhen the notice is instead the startup turn (no spawn prompt), a refusal on that turn is handled\nthe same way any other startup-prompt failure is: logged and retried, never fatal to the seat. The\nstartup prompt (or, with no spawn prompt, the notice standing in for it) stays pending while the\nnative session is busy or its bridge reconnects. Once the host invokes the request, it consumes\nthat prompt and does not retry it after an ambiguous error or close. This prevents a second\nsubmission; it cannot prove whether the first request executed.\n\nThe startup prompt excludes the automatic inbox. Messages buffered before it run in the following\nturn, including ordinary channel traffic held in `dnd`. Quiet-channel traffic remains available only\nthrough an explicit inbox pull. The connector log names a startup prompt waiting on in-flight\nsteering and a turn deferred because native state changed during that wait.\n\n## Event plane\n\nA spawned seat publishes run boundaries, assistant text, reasoning,\nand tool starts and ends on `events.<owner>.<actor>`. Tool arguments and results are not published.\nThe channel and grant rules are the same as the other connectors; see\n[Connect Claude Code](connect-claude.md#event-plane) for how to grant and read one.\n\nJcode's live Harness API reports token-sized text and reasoning deltas, but that stream cannot be\nread again after a host crash. The connector therefore reads Jcode's native append-only session\njournal under the seat's private home. The journal supplies a durable byte cursor and is keyed by\nthe Jcode session id, which is also the AG-UI thread id. A restarted seat continues from the cursor\nstored in its event write-ahead log and does not republish records already acknowledged.\n\nIf Jcode checkpoints its journal, the connector validates the saved session snapshot and ends the\ninterrupted event observations with `RUN_ERROR`. Open tool observations end before this error; this\ndoes not claim that their executions completed. The seat stays up while the next journal is absent,\nincluding during a long tool call. The reader keeps its previous cursor until it can read the new\njournal from its beginning. It does not reconstruct missing output from the snapshot. A tool result\nwhose start was not observed also ends the run with `RUN_ERROR`, not an invented tool start or an\nunpaired end. Like every published run error, both carry only the fixed message `run failed`: the\nconnector's codes `jcode_journal_fold` and `jcode_tool_start_missing` do not leave the seat. On restart, open tools are restored from the event WAL.\n\nA missing journal without a valid snapshot for the same session remains an emitter failure.\nMalformed cursors, invalid complete records and filesystem access refusals are not checkpoints.\n\nWhen the seat's mesh connection drops and the endpoint is rebuilding it, event publishing waits\nuntil the connection is live again and then publishes the queued records in order. The seat stays up\nthrough the outage. If the seat is stopped before the connection returns, the wait ends and the\nconnector log records `AG-UI emitter stopped`. The unpublished records stay in the journal\nbehind the stored cursor, and the next start publishes them. Any other emitter failure stops the\nseat with exit code 1 on a space that requires events. On any other space the seat keeps running\nwithout events.\n\nThe journal records settled message blocks rather than live deltas. Text and reasoning therefore\narrive per persisted block, and tool activity arrives when Jcode persists the tool-use and result\nblocks. User prompt text is not republished onto the event channel. The launcher sets\n`COTAL_EVENTS` by default; pass `--no-events` to opt out.\n\n\nFor a foreground launch, the TUI opens as soon as the session is ready, before the readiness turn,\nso it streams boot activity instead of leaving the terminal blank. Presence still begins only after\nthe readiness proof passes. An inbound peer message then wakes a Harness API turn. A directed message\nthat arrives while the Harness session is busy (a Cotal-owned `run()`, a TUI-owned turn, or an\nadvisory idle pulse between tool rounds of a still-open Cotal-owned run) enters Jcode's session-owned\nsoft-interrupt queue. Ambient channel traffic stays buffered for the next turn. The host marks\npresence working while the session is busy, publishes `activity` naming automatic queue depth and age\nwhile anything remains uncommitted, and acknowledges every initial or soft-interrupted inbox id only\nafter that containing turn succeeds. A failed Cotal-owned turn or private Harness replacement leaves\nthose ids unacknowledged for mesh redelivery. When the Harness reports the failure itself, such as a\nprovider `rate_limit`, the host relays its error code as the presence `condition`, so the roster and\n`cotal ps` read `waiting (rate_limit for 2m)`. A turn the TUI owns is covered too: its failure arrives as an\nunsolicited Harness error frame, and the host relays that the same way. A code outside the closed\nvocabulary reads `failed` with the native code in `condition.source`. The next turn clears it when it\nstarts, whether the host or the TUI owns that turn. The host also records every work event of its\nsession, such as a token or a tool call, as presence `activeAt`, whether the host or the TUI owns the\nturn. A turn that stopped advancing therefore shows the age of its last event, `\xB7 active 40m ago`,\nbeside a heartbeat that is still fresh. `cotal_inbox` pulls only buffered quiet\nambient from that host-owned queue; its shared optional `peek` argument is supported, so `peek: true`\nshows those messages without clearing them.\n\nA run turn rides the message that starts a Cotal-owned turn. It counts as shown once the Harness\naccepts that message, so the seat can `cotal_yield` it during the turn that carries it. A send the\nHarness never accepts leaves it unshown, and the next turn carries it again.\n\n## Model limits\n\n`--model` is passed to Jcode's session-level Harness API model selector. Jcode validates the model\nagainst the active provider, and an accepted selection becomes the session pin and the seat's model\nlabel. The connector reports the provider route actually serving that model to presence, and\n`cotal ps --wide` and `--json` show it as `provider`. The connector does not require `RuntimeInfo.model`\nto echo that pin immediately because the runtime field can temporarily report the previous model\nafter selection.\n\nModel startup refusals are named without exposing provider output: `model_prefix_rejected` means a\n`provider/model` value was supplied where the Harness API requires a bare id, `model_refused` means\nJcode rejected that bare id, and `model_mismatch` means a requested variant could not be tied to one\nactive provider route for the selected model. `private_state` names a different step: the seat's\nprivate home, its credential mirror, or its short socket alias could not be prepared.\n\nStored sessions have their own refusals. `sessions_enumeration_failed` means listing the home's\nprior sessions killed the harness. `sessions_unwritable` means the home's `sessions/` directory\nexists but will not take a write: the harness would accept the seat and die only while persisting\nits first session, so the connector refuses before that launch and names the directory and the\nerrno. Fix the directory's permissions on the seat's private state and start again; the connector\nnever repairs or widens them itself. A missing `sessions/` directory is a first launch and is left\nalone.\n\n`cotal models --agent jcode` reads the declared catalog from the operator Jcode home's\n`config.toml`: each provider with `model_catalog = true`, its `[[providers.<name>.models]]` ids,\nand any declared `reasoning_efforts`. This is the same config Jcode copies into a private managed\ninstance. The command fails loud when the file is unreadable, malformed, or enables a catalog\nwithout model entries.\n\nThe listed effort tiers are display declarations, not Jcode runtime capabilities. Jcode's named\nmodel config does not assign effort support per model. A named provider profile enables it through\nprovider configuration or Jcode's model-family detection. `cotal models` prints\nthat caveat inline as `variants (declared, not provider-verified)` beside each configured tier list,\nso it cannot be missed by reading only the model rows. Providers can reject a tier the file names,\nso launch remains the authority: Jcode applies the requested value and a provider rejection ends the\nlaunch. `--refresh` does not turn this local declaration into a live probe.\n\nThe Harness API can set a requested effort but cannot read an effective effort back. Its runtime\nidentity reports provider, model, and routes only; no reply or event carries the applied tier. Cotal\ntherefore records the accepted request and does not relabel it as an observed effect.\n\n`--variant` is the session's **reasoning effort**, applied after the model and before the seat's\nfirst turn, so a seat never serves a turn at an effort nobody chose. A persona's `variant:` is the\ndefault and `--variant` overrides it, the same way `model:` and `--model` work:\n\n```bash\ncotal spawn --agent jcode --model gpt-5.6-sol --variant high\n```\n\nWhich tiers exist depends on the provider, profile, and model. The connector does not carry a copy\nof those rules. After model selection it uses the accepted session pin with Jcode's runtime provider\nand route catalog to verify one active provider route, then passes the tier to that route. For a\nvariant-only launch, where there is no requested pin, the runtime model identifies the selection.\nA duplicate model id on another route cannot receive the setting by accident. A rejected tier ends\nthe launch with a safely parsed accepted ladder when Jcode supplies one. A verified route with no\nreasoning-effort surface also ends before the first turn, but reports unsupported capability instead\nof suggesting another tier. Arbitrary provider rejection text stays private. Omit `--variant` to\nkeep Jcode's configured default.\n\nIf the mandatory readiness turn receives a provider `invalid_request` refusal for a model id or\nreasoning-effort value, the launch diagnostic names only the provider error code and rejected\nvalue. Other provider response text remains scrubbed, so an external observer/UI can correct\nconnector-visible input without exposing private harness output.\n\nThe following fail loud before a new session is provisioned where the manager can preflight them,\nor at connector launch as a backstop:\n\n- **Exact-session continuation:** a Cotal seat owns a new private Jcode instance. Attaching it to\n a session an operator or another seat still owns would violate that ownership boundary. Use\n `--resume`, which gives the seat its own fork.\n- **Tool sharing:** Jcode resolves its MCP configuration from several global and project sources.\n The connector owns a private configuration containing only `cotal`, rather than claim a chosen\n subset can be safely merged.\n- **Launch options:** the connector does not map arbitrary flags/config into the Harness API.\n- **Containers:** the current deploy image does not bundle Jcode, so there is no containerized Jcode connector today.\n\n## Security limits\n\nThe private home protects against accidental sharing and stale session selection; it is not an\nOS-user isolation boundary. A hostile process running as the same user can still read that user's\nfiles or inspect another same-user process. Use OS/container isolation where peers must be mutually\nhostile.\n\nThe model can receive remote peer messages and Jcode is an autonomous coding harness. Treat its\nprovider credentials, filesystem access, and network capability as the privileges of the OS user\nrunning the seat. Cotal's spawn capability governs who may create a seat; it is not a sandbox for\nwhat a model can be persuaded to do after creation.\n"
62571
+ "body": "# Connect Jcode (beta)\n\n> **Guide** (informative) \xB7 **For:** operators \xB7 **Prereqs:** [Quickstart](getting-started.md)\n\n[Jcode](https://github.com/1jehuang/jcode) joins a Cotal mesh as a lateral peer. The connector\ncreates one private Jcode Harness API instance per seat, one Jcode session inside it, and exposes\nthe normal `cotal_*` tool surface through Jcode's documented stdio MCP configuration.\n\n**Beta** means the supported path is deliberately narrow: a fresh private session, a fork of one\nof your own sessions (`--resume`), prompt injection, presence, managed start/stop, requested\nreasoning effort, and an attached TUI work. Features that do not preserve that private session's\nmesh surface fail loud: exact-session continuation, `--share-tools`, and connector `--opt` values\nare not supported.\n\n## Install\n\nThe connector is seeded with the Cotal CLI. Jcode's released Harness API bridge is a Unix-socket\nsurface, so Windows is not supported. Managed seats run on Linux, macOS, and the BSDs.\nInstall Jcode 0.78.1 or later from its GitHub release and make the binary available as `jcode` on `PATH`:\n\n```bash\njcode version --json\ncotal spawn --agent jcode\n```\n\nIf an older Cotal installation is missing the connector, run `cotal ext seed --repair` (or\n`cotal ext add @cotal-ai/connector-jcode`). This connector intentionally uses the released\nbinary's `api-bridge` command; it does not require a Rust checkout.\n\n## Spawn it\n\n```bash\ncotal spawn --agent jcode\ncotal spawn reviewer --agent jcode -d\ncotal spawn --agent jcode --model gpt-5.6-sol --prompt \"Review the current change.\"\nCOTAL_DEFAULT_AGENT=jcode cotal spawn\n```\n\nA detached seat is managed normally: `cotal ps`, `cotal attach`, and `cotal stop` control the\nsame process the connector starts. In a terminal, Jcode opens on the managed session. With piped\noutput it stays headless; set `COTAL_JCODE_TUI=1` or `COTAL_JCODE_TUI=0` in the environment of the\nprocess building the launch to override that choice. For a detached spawn, that is the manager's\nenvironment. The connector refuses any value outside the on/off spellings in\n[Configuration](config.md#environment-variables) when it starts.\n\n## How it binds\n\nJcode's stable integration surface is the **Harness API**: protocol-v1 NDJSON over a Unix socket.\nThe connector launches a **private instance** with `@1jehuang/jcode-sdk`'s `launchInstance()` and\nattaches only to that instance's own socket:\n\n- `launchInstance()` starts a private `JCODE_HOME`, runtime directory, daemon, and `api-bridge`;\n the connector holds the process handle first-hand and closes that instance with the Cotal seat.\n This gives each managed Cotal peer one owned session and prevents it from seeing or changing\n the operator's live Jcode sessions.\n- Attaching to an **operator-run** `jcode api-bridge` shares the operator's live session\n inventory. That is appropriate for a dashboard or editor integration, but not a managed Cotal\n seat: stop, prompt injection, and session selection could act on the operator's work. The\n connector never attaches to an operator bridge.\n- A managed seat **never updates its own binary**. Jcode's background updater restarts the\n process tree when it lands a release; that restart drops the seat's TUI, which is the only\n connection the Jcode server counts as a client, and nothing re-attaches, so the server's idle\n reaper takes the seat down five minutes later in the middle of a turn. The seat's version is\n whatever is on `PATH` when you spawn it, and it stays that version for the seat's life. Update\n deliberately, between seats, not under a running agent.\n\nOn a graceful stop **and** on a startup failure, the connector proves the private daemon tree is\nactually gone rather than trusting the SDK's registry-keyed stop (which is a silent no-op when the\n`servers.json` socket path does not match verbatim): it reads the PIDs the private home itself\nrecords, sends a bounded SIGTERM, escalates survivors to an exact-PID SIGKILL, and reports a\nfailed stop instead of a clean one if any recorded process survives. It never signals by name, so\nteardown can only ever reach the seat's own tree.\n\nA seat that dies without that teardown, from a manager restart or a kill past the grace window,\nleaves its Jcode server running. The server has a process group of its own and carries no\n`COTAL_NAME`, so a name-keyed reap does not reach it, and it holds the seat's runtime directory\nuntil its own five-minute idle timer expires. Each launch records its identity nonce and its host\nprocess in the private home, and the seat's next launch stops the tree that record names. The\nrecorded host is the gate: while it is still alive the seat is still serving, so nothing is\nsignalled and the second launch meets Jcode's own runtime-directory lock instead.\n\nThe private Jcode home lives under `<manager-workspace>/.cotal/jcode/`. It is unique per\nspace/name and is owner-only. Jcode's own credential inheritance is used for the private instance,\nso provider logins work without copying its transcript/config tree into the seat. The spawned\nJcode process does not inherit `COTAL_*` values or the Cotal launch-material pointer.\n\nBecause the home is keyed by space and name, a seat respawned under the same space, name, and\nmanager workspace lands in the same home, and the connector automatically continues the\nnon-archived Jcode session there that was recorded for the seat's working directory and holds the\nlargest transcript, since that is the session carrying the memory a restart would otherwise throw\naway. A seat spawned under a fresh name keys a different home and starts with an\nempty transcript, so keep the same name when you want a replacement seat to continue where the\nprevious one stopped. This automatic continuation is a relaunch of the seat's own private session;\nit is separate from `--resume`, which forks an outside session into the seat (below). The short\nsocket alias the connector derives from that home is reclaimed at every launch, so a name a stopped\nseat used stays launchable.\n\n`cotal spawn --resume <id>` forks session `<id>` from your own Jcode home (`JCODE_HOME`, or the\ndefault Jcode home) into the seat's private home before the seat's instance starts. The Harness API\nhas no fork call, so the connector does what Jcode's own split does: it writes a new session whose\nparent is the source and which carries the source's messages, compaction state, system prompt, and\nmodel. The seat gets a new session id and its briefing as the first turn after that history. The\nsource files are only read, so the source transcript is never appended to. A session id with no\nreadable transcript, or one whose snapshot or journal holds fields Jcode cannot load, is refused\nbefore the seat launches; every carried message, content block, and compaction state is checked\nagainst Jcode's own schema, and the refusal names the field. Jcode stores its counts as u64 and\nreads one only as a plain decimal integer, so a count above what a JavaScript number holds is copied\nbyte for byte, and one above the u64 range or spelled with a fraction or an exponent (`1e3`, `1.0`)\nis refused. The source may be live: the connector\nreads it until two reads agree and every journal line is newer than the snapshot, so a Jcode\ncheckpoint caught mid-way is neither lost nor applied twice, and a session that keeps changing is\nrefused with a request to retry. A seat relaunched under the same name continues its\nfork rather than forking again, without reading the source, which may since have been deleted. The\nseat's briefing is recorded separately from the fork, so a first launch that fails after forking\nstill briefs the seat on its next launch. The seat records the source session id, its title, and a\nSHA-256 of the snapshot and journal it read, prints them when it forks, and the manager reads that\nrecord into the seat's resume document, so `cotal ps --wide` shows them. The manager keeps a title\nof at most 1024 characters, so a source with a longer title is refused before the seat launches.\n\nConnector diagnostics are written both to the spawning terminal and to an owner-only\n`<private-home>/logs/connector-<timestamp>-<pid>.log`, so a failed launch remains inspectable after\nthe manager's launch error scrolls away. Public startup failures stay scrubbed to allow-listed\ncodes rather than arbitrary Harness API messages.\n\nCredential mirroring is mandatory for managed Jcode seats: each launch atomically refreshes the\nallowlisted Jcode, provider-config, and external-login destinations, and removes a destination when\nits source login was removed. Cleanup addresses only that explicit inventory; transcripts, MCP\nconfiguration, logs, and other private-home state are untouched. Copy, mkdir, and removal walk the\nparents with `O_NOFOLLOW`, then publish, create, or unlink the leaf through the pinned parent rather\nthan through a path the kernel re-walks. Replacing a walked directory with a symlink cannot write,\ncreate, or delete a namesake outside the private home.\n\nTwo mechanisms provide that pin. On Linux the leaf is named `/dev/fd/<fd>/<name>`, the openat and\nunlinkat equivalent Node does not expose, after `/dev/fd/<fd>/.` is proven to traverse. macOS mounts\n`/dev/fd` but has no subpath namespace under a descriptor, so there the connector pins the parent as\nthe process working directory instead: a single-component name resolves from that directory's inode\nand no ancestor is walked again. Entering by path is verified rather than trusted, because `chdir`\ntakes a path: the entered directory's inode must equal the inode of the descriptor opened a moment\nearlier, and a mismatch is refused by name. The working directory is restored on every exit,\nincluding the refusing ones. If neither pin is available, the connector throws a named error and\nmirrors nothing. Once a pin holds, `ENOENT` on a child means the mirror path is absent.\n\nThere is no credential-free opt-out today because the private instance must reproduce the operator's\ncurrent provider-login state rather than silently start with stale or partial authorization.\n\nIf a provider failure closes the private Harness API connection during a mesh-driven turn, the\nconnector leaves that turn's inbox batch unacknowledged and opens one bounded recovery window for a\nprivate replacement connection to the same session. The window lasts 60 seconds from the close, and\nstopping the broken private tree counts against it. A transient launch or attach failure retries\ninside that window, so a loaded host gets the same result as a fast one without creating an\nunbounded connector relaunch loop. The seat reports `waiting` while it reconnects, then redrives\nthat unacknowledged batch only after the session attaches. Each failed replacement must be proven\nstopped before another launch. A permanent Harness refusal, including an invalid request, missing\nsession, protocol mismatch, missing binary, or socket permission denial, ends the seat immediately;\nanother launch cannot change it. An unprovable teardown, the recovery window expiring, or a second\ndisconnect after a successful replacement also ends the seat. When a turn failed on a Harness error\nand no turn has succeeded since, whether the host or the TUI owned it, the connector line that ends\nthe seat this way also names that error as `last turn error: <message>`, so the manager's\n`seat reaped:` line carries it without a read of the seat's private log. The reap line keeps only the\nfirst 240 characters, so that field comes before the error of a failed recovery, which then reads\n`recovery error: <message>`. An unrecognized Harness SDK error\ncode remains transient by default and retries inside the same bounded window; new permanent codes\nmust be added to the explicit classifier and its exact-count regression.\n\nJcode currently supports **stdio** MCP servers. The connector writes only its own `cotal` entry to\nthe private `JCODE_HOME/mcp.json`; it starts a stdio MCP bridge for that entry and relays its calls\nto the host's one `MeshAgent`. The Jcode/MCP child receives a per-launch relay capability, but not\nthe Cotal broker credential or its launch-material pointer. Jcode also overlays project\n`.jcode/mcp.json`, `.mcp.json`, and `.claude/mcp.json`; a managed launch **refuses** a workspace\ncontaining any of those files, because one could replace the `cotal` bridge or add tools that were\nnot explicitly shared. Operator MCP configuration is isolated in the private home and project MCP\nconfiguration is not supported yet.\n\nBefore the seat joins the mesh, the host runs a mandatory Jcode turn that calls\n`cotal_orientation`. Jcode loads MCP tools asynchronously; its first turn can use the pre-MCP tool\nsnapshot immediately before Jcode rebuilds that snapshot. The host repeats the identical proof once\nin that case. A second absence fails the launch, so a bridge that never comes up remains a loud\nfailure rather than an agent that is present but mute. The persona is already in that transcript as\na no-reply message; the spawn `--prompt` is not submitted until after join. While the proof is in\nflight the connector log names `pre-join readiness` and the bound. That in-flight line only means\nstartup reached the gate; it is not a hang, a missing prompt, or a provider refusal by itself.\nAfter the turn, a second line names the outcome and is what separates those cases:\n`orientation proved; joining with no spawn --prompt` or `joining, then submitting the spawn\n--prompt` when the proof passed; `provider refusal` when the provider rejected the turn; `timeout`\nwhen the bound fired. A genuine hang that never returns and never hits the bound has no outcome\nline. A one-line log with no route line is not that signal. The proof itself is bounded to the\nsame three minutes the connector declares to the manager (`readinessTimeoutMs` is the exported\n`JCODE_READINESS_TIMEOUT_MS`). Tests may shorten the host bound through\n`COTAL_JCODE_READINESS_TIMEOUT_MS`; that override is not an operator setting and does not change\nthe window the connector declares to the manager. The bound is a call-observation deadline: the\nhost proves readiness on the orientation `tool_done` event as it arrives, without waiting for\n`turn_done`. If that call never arrives inside the window the host exits `readiness_timeout` and\nnever joins, rather than working invisibly. That teardown does not wait for the in-flight turn:\nit kills the private Jcode tree and discards whatever that turn had generated. Nothing from it\nis recoverable; inspect the seat connector log for the timeout outcome, then spawn again. The\ntimeout line names whether the orientation call was observed. A seat that already made the call\njoins even if that proof turn is still open; the host does not destroy a functional session\nsolely because `turn_done` has not arrived. The manager's wait can still report `uncertain` when\njoin itself is slow after a passing proof; that is not a cleanup verdict, and it is not the same\nas a host `readiness_timeout`. Use `cotal attach <name>` or `cotal ps` to inspect an `uncertain`\nlaunch. The\nhost then waits for the mesh connection and presence bind to complete before it delivers a notice\nthat the bootstrap orientation predates the join and that a new orientation is live context. During\na broker outage, it stays waiting and sends no connected notice.\n\nA seat launched with a spawn `--prompt` gets that notice as a no-reply append; the prompt is still\nthe seat's first driven turn. A seat launched with no spawn `--prompt` has nothing else to schedule\na turn after join, so the notice is delivered as the seat's first driven turn instead of a no-reply\nappend: the same dispatch boundary and one-shot rule the startup prompt uses, so the seat's final\nstartup state is never an unread append.\n\nA refused post-join notice sent as a no-reply append is logged without ending the joined session.\nWhen the notice is instead the startup turn (no spawn prompt), a refusal on that turn is handled\nthe same way any other startup-prompt failure is: logged and retried, never fatal to the seat. The\nstartup prompt (or, with no spawn prompt, the notice standing in for it) stays pending while the\nnative session is busy or its bridge reconnects. Once the host invokes the request, it consumes\nthat prompt and does not retry it after an ambiguous error or close. This prevents a second\nsubmission; it cannot prove whether the first request executed.\n\nThe startup prompt excludes the automatic inbox. Messages buffered before it run in the following\nturn, including ordinary channel traffic held in `dnd`. Quiet-channel traffic remains available only\nthrough an explicit inbox pull. The connector log names a startup prompt waiting on in-flight\nsteering and a turn deferred because native state changed during that wait.\n\n## Event plane\n\nA spawned seat publishes run boundaries, assistant text, reasoning,\nand tool starts and ends on `events.<owner>.<actor>`. Tool arguments and results are not published.\nThe channel and grant rules are the same as the other connectors; see\n[Connect Claude Code](connect-claude.md#event-plane) for how to grant and read one.\n\nJcode's live Harness API reports token-sized text and reasoning deltas, but that stream cannot be\nread again after a host crash. The connector therefore reads Jcode's native append-only session\njournal under the seat's private home. The journal supplies a durable byte cursor and is keyed by\nthe Jcode session id, which is also the AG-UI thread id. A restarted seat continues from the cursor\nstored in its event write-ahead log and does not republish records already acknowledged.\n\nIf Jcode checkpoints its journal, the connector validates the saved session snapshot and ends the\ninterrupted event observations with `RUN_ERROR`. Open tool observations end before this error; this\ndoes not claim that their executions completed. The seat stays up while the next journal is absent,\nincluding during a long tool call. The reader keeps its previous cursor until it can read the new\njournal from its beginning. It does not reconstruct missing output from the snapshot. A tool result\nwhose start was not observed also ends the run with `RUN_ERROR`, not an invented tool start or an\nunpaired end. Like every published run error, both carry only the fixed message `run failed`: the\nconnector's codes `jcode_journal_fold` and `jcode_tool_start_missing` do not leave the seat. On restart, open tools are restored from the event WAL.\n\nA missing journal without a valid snapshot for the same session remains an emitter failure.\nMalformed cursors, invalid complete records and filesystem access refusals are not checkpoints.\n\nWhen the seat's mesh connection drops and the endpoint is rebuilding it, event publishing waits\nuntil the connection is live again and then publishes the queued records in order. The seat stays up\nthrough the outage. If the seat is stopped before the connection returns, the wait ends and the\nconnector log records `AG-UI emitter stopped`. The unpublished records stay in the journal\nbehind the stored cursor, and the next start publishes them. Any other emitter failure stops the\nseat with exit code 1 on a space that requires events. On any other space the seat keeps running\nwithout events.\n\nThe journal records settled message blocks rather than live deltas. Text and reasoning therefore\narrive per persisted block, and tool activity arrives when Jcode persists the tool-use and result\nblocks. User prompt text is not republished onto the event channel. The launcher sets\n`COTAL_EVENTS` by default; pass `--no-events` to opt out on an unrestricted space. A registration\nthat requires events refuses `--no-events`, and its launch always carries the flag and the workspace\nroot the event log lives under. A hand-driven user-mode launch with `COTAL_EVENTS_REQUIRED=1` arms\nthe plane without that flag.\n\n\nFor a foreground launch, the TUI opens as soon as the session is ready, before the readiness turn,\nso it streams boot activity instead of leaving the terminal blank. Presence still begins only after\nthe readiness proof passes. An inbound peer message then wakes a Harness API turn. A directed message\nthat arrives while the Harness session is busy (a Cotal-owned `run()`, a TUI-owned turn, or an\nadvisory idle pulse between tool rounds of a still-open Cotal-owned run) enters Jcode's session-owned\nsoft-interrupt queue. Ambient channel traffic stays buffered for the next turn. The host marks\npresence working while the session is busy, publishes `activity` naming automatic queue depth and age\nwhile anything remains uncommitted, and acknowledges every initial or soft-interrupted inbox id only\nafter that containing turn succeeds. A failed Cotal-owned turn or private Harness replacement leaves\nthose ids unacknowledged for mesh redelivery. When the Harness reports the failure itself, such as a\nprovider `rate_limit`, the host relays its error code as the presence `condition`, so the roster and\n`cotal ps` read `waiting (rate_limit for 2m)`. A turn the TUI owns is covered too: its failure arrives as an\nunsolicited Harness error frame, and the host relays that the same way. A code outside the closed\nvocabulary reads `failed` with the native code in `condition.source`. The next turn clears it when it\nstarts, whether the host or the TUI owns that turn. The host also records every work event of its\nsession, such as a token or a tool call, as presence `activeAt`, whether the host or the TUI owns the\nturn. A turn that stopped advancing therefore shows the age of its last event, `\xB7 active 40m ago`,\nbeside a heartbeat that is still fresh. `cotal_inbox` pulls only buffered quiet\nambient from that host-owned queue; its shared optional `peek` argument is supported, so `peek: true`\nshows those messages without clearing them.\n\nA run turn rides the message that starts a Cotal-owned turn. It counts as shown once the Harness\naccepts that message, so the seat can `cotal_yield` it during the turn that carries it. A send the\nHarness never accepts leaves it unshown, and the next turn carries it again.\n\n## Model limits\n\n`--model` is passed to Jcode's session-level Harness API model selector. Jcode validates the model\nagainst the active provider, and an accepted selection becomes the session pin and the seat's model\nlabel. The connector reports the provider route actually serving that model to presence, and\n`cotal ps --wide` and `--json` show it as `provider`. The connector does not require `RuntimeInfo.model`\nto echo that pin immediately because the runtime field can temporarily report the previous model\nafter selection.\n\nModel startup refusals are named without exposing provider output: `model_prefix_rejected` means a\n`provider/model` value was supplied where the Harness API requires a bare id, `model_refused` means\nJcode rejected that bare id, and `model_mismatch` means a requested variant could not be tied to one\nactive provider route for the selected model. `private_state` names a different step: the seat's\nprivate home, its credential mirror, or its short socket alias could not be prepared.\n\nStored sessions have their own refusals. `sessions_enumeration_failed` means listing the home's\nprior sessions killed the harness. `sessions_unwritable` means the home's `sessions/` directory\nexists but will not take a write: the harness would accept the seat and die only while persisting\nits first session, so the connector refuses before that launch and names the directory and the\nerrno. Fix the directory's permissions on the seat's private state and start again; the connector\nnever repairs or widens them itself. A missing `sessions/` directory is a first launch and is left\nalone.\n\n`cotal models --agent jcode` reads the declared catalog from the operator Jcode home's\n`config.toml`: each provider with `model_catalog = true`, its `[[providers.<name>.models]]` ids,\nand any declared `reasoning_efforts`. This is the same config Jcode copies into a private managed\ninstance. The command fails loud when the file is unreadable, malformed, or enables a catalog\nwithout model entries.\n\nThe listed effort tiers are display declarations, not Jcode runtime capabilities. Jcode's named\nmodel config does not assign effort support per model. A named provider profile enables it through\nprovider configuration or Jcode's model-family detection. `cotal models` prints\nthat caveat inline as `variants (declared, not provider-verified)` beside each configured tier list,\nso it cannot be missed by reading only the model rows. Providers can reject a tier the file names,\nso launch remains the authority: Jcode applies the requested value and a provider rejection ends the\nlaunch. `--refresh` does not turn this local declaration into a live probe.\n\nThe Harness API can set a requested effort but cannot read an effective effort back. Its runtime\nidentity reports provider, model, and routes only; no reply or event carries the applied tier. Cotal\ntherefore records the accepted request and does not relabel it as an observed effect.\n\n`--variant` is the session's **reasoning effort**, applied after the model and before the seat's\nfirst turn, so a seat never serves a turn at an effort nobody chose. A persona's `variant:` is the\ndefault and `--variant` overrides it, the same way `model:` and `--model` work:\n\n```bash\ncotal spawn --agent jcode --model gpt-5.6-sol --variant high\n```\n\nWhich tiers exist depends on the provider, profile, and model. The connector does not carry a copy\nof those rules. After model selection it uses the accepted session pin with Jcode's runtime provider\nand route catalog to verify one active provider route, then passes the tier to that route. For a\nvariant-only launch, where there is no requested pin, the runtime model identifies the selection.\nA duplicate model id on another route cannot receive the setting by accident. A rejected tier ends\nthe launch with a safely parsed accepted ladder when Jcode supplies one. A verified route with no\nreasoning-effort surface also ends before the first turn, but reports unsupported capability instead\nof suggesting another tier. Arbitrary provider rejection text stays private. Omit `--variant` to\nkeep Jcode's configured default.\n\nIf the mandatory readiness turn receives a provider `invalid_request` refusal for a model id or\nreasoning-effort value, the launch diagnostic names only the provider error code and rejected\nvalue. Other provider response text remains scrubbed, so an external observer/UI can correct\nconnector-visible input without exposing private harness output.\n\nThe following fail loud before a new session is provisioned where the manager can preflight them,\nor at connector launch as a backstop:\n\n- **Exact-session continuation:** a Cotal seat owns a new private Jcode instance. Attaching it to\n a session an operator or another seat still owns would violate that ownership boundary. Use\n `--resume`, which gives the seat its own fork.\n- **Tool sharing:** Jcode resolves its MCP configuration from several global and project sources.\n The connector owns a private configuration containing only `cotal`, rather than claim a chosen\n subset can be safely merged.\n- **Launch options:** the connector does not map arbitrary flags/config into the Harness API.\n- **Containers:** the current deploy image does not bundle Jcode, so there is no containerized Jcode connector today.\n\n## Security limits\n\nThe private home protects against accidental sharing and stale session selection; it is not an\nOS-user isolation boundary. A hostile process running as the same user can still read that user's\nfiles or inspect another same-user process. Use OS/container isolation where peers must be mutually\nhostile.\n\nThe model can receive remote peer messages and Jcode is an autonomous coding harness. Treat its\nprovider credentials, filesystem access, and network capability as the privileges of the OS user\nrunning the seat. Cotal's spawn capability governs who may create a seat; it is not a sandbox for\nwhat a model can be persuaded to do after creation.\n"
62569
62572
  },
62570
62573
  {
62571
62574
  "slug": "connect-opencode",
@@ -62740,7 +62743,7 @@ function loadDocsBundle() {
62740
62743
  "title": "Workflow runs",
62741
62744
  "kind": "Concept (informative)",
62742
62745
  "summary": "A workflow run is a program that coordinates agents over hours or days and survives the process that started it.",
62743
- "body": "# Workflow runs\n\n> **Concept** (informative) \xB7 **For:** people writing a durable multi-agent workflow, and implementers hosting one \xB7 **Normative:** [SPEC \xA714](../SPEC.md#14-workflow-runs-v05) and the language reference [`spec/cotal-lang.md`](../spec/cotal-lang.md)\n\nA **workflow run** is a program that coordinates agents over hours or days and survives the\nprocess that started it. The program is written in **Cotal Lang**, a small subset of JavaScript in\nwhich every interaction with the world is one of a dozen **effects** (`spawn`, `turn`, `ask`,\n`checkpoint`, `sleep`, `wait`, `notify`, `monitor`, and the four concurrency scopes) and everything\nelse is ordinary, pure JavaScript. Every effect is written into the run's **step journal** before\nit is performed and settled after, keyed by where in the program it happened rather than by when,\nso a run that dies is resumed on any host by **re-running the program from the top** with recorded\neffects returning their recorded results. Nothing about the interpreter is ever serialized: the\njournal and the program are the whole state.\n\n## A first program\n\n```js\nconst planner = await spawn(\"planner\")\nconst builder = await spawn(\"builder\", { worktree: \"wt-1\" })\n\nconst plan = await ask(planner, { name: \"plan\", schema: { steps: \"array\" } })\nconst ok = await checkpoint(\"approve-plan\", \"Approve the plan?\", { timeout: \"4h\", onExpiry: \"proceed\" })\nif (ok.status !== \"resolved\") {\n await notify([planner], { decision: \"approve-plan\", outcome: \"expired\" })\n}\n\nconst r = await turn(builder, { name: \"build\", deadline: \"30m\" })\nif (r.status === \"blocked\") {\n await turn(planner, { name: \"unblock\" })\n}\n\nconst outcome = await race({\n reply: () => wait(replied(builder), { timeout: \"20m\" }),\n giveUp: () => sleep(\"1h\"),\n}, { name: \"await-or-move-on\" })\nlog(\"outcome\", outcome.index)\n```\n\nRead it as the flowchart it is. `spawn` brings agents in; `ask` is the narrow case where the\nprogram itself needs a value (`schema` is a record the program hands the handler unchanged; the\nlanguage hashes it and gives it no meaning, and the handlers in this repository enforce it as the\nshorthand of the language reference \xA76.5);\n`checkpoint` is a durable pause a human resolves from anywhere, raced against a durable timer; `turn`\nwakes an agent for one turn and returns how it yielded; `race` runs two branches and keeps the one\nwhose recorded clock is earliest. Agents talk to each other in channels as they always do; the\nprogram never speaks in a channel, and the one thing it can put in front of an agent (`notify`) is a\nbounded decision record, not prose.\n\n## The mental model\n\n- **Pure code is JavaScript.** Loops, records, arrays, closures, template literals, destructuring,\n `try`/`catch`, arithmetic, `switch`, compound assignment, optional chaining, spread and rest: what\n you would write anyway, with the parts that hide effects or make meaning depend on the host removed\n (`class`, `this`, `new`, `for...in`, `==`, labels, regex literals, `Math`/`Date`/`JSON`, promises,\n generators). Every refusal names its code and the edit that fixes it. The builtins are a short list\n (`keys`, `map`, `sort`, `json.stringify`, `now()`, `random()`), and arrays, strings and numbers\n answer their usual methods (`xs.map`, `s.trim()`, `n.toFixed()`) and nothing outside that table.\n Records and arrays you build are yours to change until they cross an effect boundary; a member you\n do not own, a host prototype, or a value another branch built is refused with a code, never a\n surprise.\n- **Every effect is journalled and hashed.** A step is keyed `(scope path, kind, name, occurrence)`\n and its inputs are hashed. Reorder your program, add a step, rename a variable: recorded steps\n still match. Change what a step asks (a checkpoint's prompt, a sleep's duration, a turn's\n deadline) and the resume stops with a **divergence** naming the step, rather than replaying an\n answer to a question the program no longer asks.\n- **Concurrency is visible.** `parallel`, `race`, `fanOut` and `conclave` are the only ways to do\n two things at once, each branch gets its own journal namespace, and the scope writes its own\n entry saying how it settled: which arm won a race is a recorded fact, decided by the arms'\n recorded clocks and declaration order, never by a scheduler. A failure the program itself caused\n inside the scope settles the entry under its own catalog code (`fanOut` without a stable key is\n `L3021`, kind `runtime`), so a resume reads the same code the live run threw; a plain failure\n from the handler records the generic `L4000` `scope-fault`. A value the program throws inside a\n branch is recorded with that fault, so `catch` receives the same value on resume, and in a\n migration's dry walk, as it did live.\n A branch may not write to anything declared outside it; return the value and read it out of the\n scope's result.\n- **Time and randomness are tamed.** `now()` is the branch's run clock, the end of the last effect\n it awaited; `random()` is a seeded stream derived per scope. Both replay identically.\n- **Values freeze at the boundary.** What crossed into or out of an effect is what the journal\n recorded, and it cannot change afterwards; build a new value.\n- **The journal is the debugger.** Every entry carries its key, its inputs' hash, its outcome and\n its timing, and every error is in the program's own coordinates. A run can be **simulated** with a\n scripted handler and **dry-run** to a plan before it touches an agent. The simulator is\n discrete-event: timed effects park at their wake times and are delivered in wake order on one\n virtual clock, so concurrent branches accumulate the durations they wrote and a simulated `race`\n is decided by the same rule a live handler produces (least recorded clock, ties by declaration\n order). A `sleep(\"1m\")` arm beats a `sleep(\"1h\")` arm whatever their declaration order. Behind the\n worker bridge the simulator delivers its next wake only after the thread reports it has reacted\n to the last one, so a bridged simulation settles a race the same way, also when the dry run's\n recorder wraps the simulator.\n\nFull rules, with every code: [`spec/cotal-lang.md`](../spec/cotal-lang.md).\n\n## Continuing a run\n\n**Resume** is re-execution: the driver replays the journal, the program runs from the top, recorded\nsteps return instantly, and the first unrecorded step is performed live. It refuses a journal that\nbelongs to another run, a pin that differs from the recorded ones, and a different language version.\n\nA failed journal entry replays its error, while a pending entry lets the handler recover the\nexternal work it bound before the interruption.\n\nA step that writes to a far side that honors no idempotency key (posting a comment, sending a mail)\nbelongs in `once`. A resume that finds a step inside `once` begun and never settled does not\ndispatch it again: it opens a hold, a checkpoint under a token derived from the step's recorded\nrequest id, whose prompt names that id. Answer it with\n`cotal run answer <run> <step-key> --value <json>`, and the value becomes the step's result. An\nexpired hold fails the step with the catchable L4027. Only `ask` runs inside `once`, so wrap just\nthe step that writes: a host restart while it is in flight costs a settle.\n\n```js\nconst publisher = await spawn(\"publisher\")\nconst res = await once(async () => {\n return await ask(publisher, { name: \"publish\", schema: { commentId: \"number\" } })\n}, { name: \"publish-360\" })\n```\n\n**Migrate** moves a run onto edited source. A dry walk of the new program over the recorded journal\nfinds every recorded step the edit changed (a divergence) and every one it no longer reaches (an\norphan), and the orphan table says what each means: a removed `sleep` is nothing, a removed `turn`\nalready happened, a removed `spawn` is a live agent you must adopt or release, a removed resolved\n`checkpoint` is a human decision you must explicitly discard. The decision is filed as a\n`migration` record with the actor's name on it. An adopted seat (`--adopt <name>#<uid>`) goes to\nthe edited program's next `spawn` of that persona, which returns the recorded handle and mints\nnothing, so the agent keeps its identity, its worktree and its turn history across the edit. A\nreleased seat (`--release <name>#<uid>`) is despawned when the migration commits, through the same\ndischarge a cancelled branch's seat leaves by, so the record never claims a release nothing did.\nThe spawn that adopts a seat binds the orphaned spawn's goal as its own, so a resume of that step\nreads the same seat back and a cancellation of it despawns the seat it holds.\n\n**Fork** starts a new run from a named step of an old one, copying the prefix under the parent's\npins (seed included, so the copied history's pure draws are the same draws). The child is a new run\nunder a new id whose record names the parent and the cut step (`forkedFrom`); the parent is\nuntouched. A spawn inside the copied prefix is honoured by its `onFork`: `\"adopt\"` copies it, and\nthe child shares the parent's agent (the manager shows that seat one turn at a time across both\nruns); `\"respawn\"`, the default, would mint a fresh identity the copied turns do not address, so\nthis host refuses that cut (L5019) rather than rewriting the parent's history.\n\nFork planning and migration inspection use the recorded language version. Version-1 history uses\nthe interpreter. Version-2 history is inspected inside a locked-down worker with a read-only\njournal, without a live effect handler or durable store. Inspection stops before the fork's cut\nstep or any effect that needs new work. Program catch and finally blocks cannot extend the cut.\nThe recorded pins are preserved.\n\n## From an agent session\n\nFresh `cotal setup` defaults declare `capabilities: [spawn, run]`. On an authenticated mesh,\nthat exposes `cotal_run` alongside the teammate tools. The selected manager must support execution.\nRead `cotal_docs` pages `lang-card` and `workflows`, then try:\n\n```json\n{\n \"verb\": \"start\",\n \"source\": \"await sleep(\\\"1s\\\", { name: \\\"first-run\\\" });\",\n \"file\": \"first-run.cotal.js\"\n}\n```\n\nPass this object to `cotal_run`. `source` contains the program; `file` only labels diagnostics\nand reads nothing from disk. The response returns a run ID before execution finishes. Call\n`cotal_run` with `verb: \"status\"` and that `runId` to inspect the state and step journal.\nA completed timer records its sleep step as `ok`.\n\n### If `cotal_run` is missing\n\n1. Call `cotal_orientation` and check the connector version, capabilities and tool list.\n Upgrade an older installation using the [upgrade guide](https://github.com/Cotal-AI/Cotal/blob/main/docs/UPGRADING.md).\n2. Have the operator add `run` to the persona's existing `capabilities` list, for example\n `capabilities: [spawn, run]`. `spawn` alone does not expose `cotal_run`. Setup leaves existing\n personas unchanged except for its [byte-exact legacy migration](getting-started.md).\n Peer persona-definition tools cannot grant capabilities.\n3. Relaunch the agent through the manager from the updated persona so it receives newly issued\n credentials and a fresh connector configuration. Editing the file or reconnecting with the\n old credential does not grant new broker permissions. If the launch sets `COTAL_CAPABILITIES`,\n update that override too; it takes precedence over the file.\n4. Check `cotal_orientation` again, then call `cotal_run` with `verb: \"ps\"` before starting work.\n\nTool visibility alone does not establish execution support. Hosted runs require issued caller\nauthority. Static authentication issues it to its credentials. On a user-auth mesh, a registered\nparticipant manager hosts authorized runs. A managed agent invokes `cotal_run` through its own\nlauncher-provided bearer command and immutable manager instance selection. Its current managed\ngrant and its direct parent's current grant must both carry `run`. A removed grant, stale lifecycle\nor wrong manager selection refuses rather than borrowing a signed-in user's authority.\n\nOpen meshes may expose the tool but refuse hosted runs. A local user-mode host without remote\nrun authority and an `execution: \"none\"` control manager also refuse the family. A legacy\ncredential without issued authority must be replaced through the current issuance path.\n\n## Operating a run\n\nThe manager hosts runs. `cotal run start` hands the program to the manager of the resolved mesh\n(the usual `--space` / `--server` / `--creds` flags), which validates it, mints the run id, drives\nit in its own process, and answers with the id once the run is recorded. The terminal is free the\nmoment the id prints; the run continues on the manager through every pause, and a manager restart\ntakes back every run it had recorded running, from the journal, under the next epoch. `resume`\nnames a run the manager recorded and is refused while the manager is already driving it. `ps` and\n`journal` read; `answer` resolves an open checkpoint, or an open `ask` attempt, from any terminal\nor agent that holds the `run` capability.\n\n`journal` prints an open pause's question under its step. Once a checkpoint or `ask` settles with an\naccepted answer, it instead prints the answer value as JSON, who answered, the artifact when one was\ncited, the recorded time, and the accepted answer id. Expired pauses and ordinary steps print no\nanswer line.\n\nA settled step is never answered twice, so a participant who changes their mind uses `amend`. It\nfiles a new answer beside the accepted one, naming the answer it supersedes, and `journal` prints\neach amendment under the step as an `amended` line, in the order the store committed them. The last\nline is the current position, whatever clock each amender's `at` came from. The pause stays settled\nand the run keeps the answer it acted on. A step that is still open, or that settled with no answer,\nrefuses an amend. The manager records the amender from the credential, as for an answer, and a\nspawned seat may amend only an answer recorded under its own name.\n\n```bash\ncotal run start --file build.cotal.js # the manager starts it; the minted id is printed\ncotal run ps # list run records: state, holder, lineage\ncotal run journal run-3f2a90c41b7e0d5a6c884e19b02df4a1 # print the durable step journal\ncotal run resume run-3f2a90c41b7e0d5a6c884e19b02df4a1 # the manager takes the run back\ncotal run answer run-3f2a90c41b7e0d5a6c884e19b02df4a1 \"/checkpoint:approve#0\" --value '\"yes\"'\ncotal run amend run-3f2a90c41b7e0d5a6c884e19b02df4a1 \"/checkpoint:approve#0\" --value '\"no\"' # record a changed position\ncotal run migrate run-3f2a90c41b7e0d5a6c884e19b02df4a1 --local --file build-v2.cotal.js # check an edited program against the journal\n```\n\nA program that does not validate is refused before anything is recorded, with every problem in the\nanswer as the validator would print it. The driver records the program beside the run, so `resume`\ntakes the run id alone and the manager reads the source back; an edited program is a `migrate` or a\n`fork`, never a resume. `cotal run migrate <runId> --local --file <program>` is that check: it\nreplays the run's journal and walks the edited program over it, prints whether the migration is\nadmissible, how many journal rows the walk accounted for, every orphaned step with its verdict and\ncode, and exits 0 on admissible and non-zero on not. It reads only, under the same credential\n`journal` reads on, and the commit side is not reachable yet: the report itself says what a commit\nwould file and that this invocation filed nothing. An answer is recorded under the answerer the\nmanager knows from the caller's credential: a managed agent by its name, anyone else by their\nprincipal. The request\ncarries no name. An agent with `capabilities: [run]` has the same five verbs as the `cotal_run`\ntool ([MCP tools](mcp-tools.md)), so a program can be written and started from inside a session.\nA `start` or `resume` answers once the run's record is written, within a bounded wait; a manager\nthat is still taking back a predecessor's runs at boot refuses both with `unavailable`, and a\nretry a moment later is the whole remedy.\n\n`--local` drives the run in this process instead: `start`, `resume` and `answer` exit when the\ndrive settles, `--by <who>` names the answerer, and `cotal run resume <runId> --local --file\n<program>` is how a run with no recorded program is continued. On a static mesh the local drive\nmints the run's own credential from the folder's trust material, so it runs from the mesh's\nproject folder. A local start also names the run's channel ceiling itself:\n`--admit-read <channels> --admit-publish <channels>`, comma-separated patterns or `none`, both\nrequired. The record it writes says an operator admitted the run and why, and the host checks\nit the same way it checks a hosted admission. A registered remote user-auth manager can host a\nrun through its issuing host. The host resolves a versioned caller against live issuance,\nadmits the run, and signs only the run's fixed driver, mediator and one-shot operator credentials\nfor manager-held nkeys. Renewal checks the activated attempt; the manager holds no signer.\nLocal user-auth runs remain unavailable because a user bearer holds no run rows. An open mesh\nhosts none either, since it issues no caller authority to admit a run under.\n\nA logged-in user starts runs on that remote manager with `cotal run start`. The auth callout issues\nthe user's manager connection against the user's actor-ledger row, the CLI reads the generation\nback from the connection's accepted row, and every `run` verb rides the versioned rail. The issuing\nhost admits a run only for the owner who registered the manager, and on every resume and answer it\nchecks that owner and that the caller's issuance is still live. It also watches the start, resume\nand answer requests on the broker itself, and admits or issues for one the manager forwards only if\nit saw that request, and only once. A resume or answer gets issuance only for the run, step, endpoint\nand amendment that request named. An answer's\ncredential reaches one pause, and the host reads which one off the run's own journal, for a run\nadmitted on that manager's instance. A request bound to another manager instance or epoch gets nothing, and so does one whose class or pinned contract is not the one the manager registered. Another user's start, answer or resume is refused, and so is a revoked actor's.\nSuch a run spawns, turns and despawns agents owned by that user. A spawn has the reach of the user\nwho started the run, as their actor-ledger row reads at that moment, and the host enrolls each agent\nthrough its managed-agent enrollment. A spawn may be placed on the manager that hosts the run and on\nno other instance. [User-auth run start](design/user-auth-run-start.md)\nrecords the path.\n\nA hosted run is **admitted** under the caller that started it. The caller's credential is an\nissuance ([identity and auth](identity-and-auth.md#issued-authority)): its requests ride a\nversioned rail that carries the credential's generation, and the manager resolves that\ngeneration's recorded permission ceiling and writes it beside the run before the driver starts.\nThat ceiling, the caller's own channel scope as it was issued, is what the run may read and post\nin channels; the manager's own reach never stands in for it. A request from a credential minted\nwithout an issuance is refused with `permission-denied` and a detail naming the caller. A run\nwhose caller had no channels can still sleep, checkpoint and turn agents; its `wait` on a channel\nis refused at the effect.\n\n`cotal run revoke <runId> --local --by <who> --reason <text>` writes the run's revocation marker\nfrom the project folder. An empty `--by` or `--reason` is refused before anything is written. The\nadmission itself is never rewritten. Every host reads the marker\nbefore its next channel effect, so an open `wait` refuses at its next poll, and no resume,\ntakeover or manager restart continues the run. Revoking twice is not an error, and the first\nreason stands. A run whose admission is missing or revoked is left parked by the manager's boot\nreconcile, named in its log.\n\n`run ps`, hosted or `--local`, reads the marker beside each run record and prints `revoked` for a\nrun that carries one, whatever state the record itself holds, with the revoker and the reason\nunder the table. The record is display only here: a revoke writes no terminal state, because no host drove\nthe run to one and the journal owns the facts. A marker the listing cannot read, whether the store\nis unreachable or the marker has a version or shape it does not know, prints `unchecked` in the\n`STATE` column. The reason and the state the record carries go to stderr, and the command exits 1\nonce every row is printed. The hosted `run-ps` rows carry the marker as `revoked` (`by` and\n`reason`) or a failed read as `revocationUnreadable`, beside the record's own `state`.\n\nA run whose step was refused (L5016) stays held; a\nresume on a host that can perform the step performs it live and continues from there.\n`journal` prints what an open pause asks beneath its step key, which is the address `answer` takes\nback. Checkpoint expiry rides the mediated timer writer, which the delivery daemon pumps on a live\nmesh; on a bare broker a pause still resolves, it just cannot expire.\n\n## What is on the wire\n\nThe run's wire footprint is [SPEC \xA714](../SPEC.md#14-workflow-runs-v05):\n\n| Thing | Where | What it is |\n| --- | --- | --- |\n| the run | `run.<endpoint>.<runId>` record | the resolved **pins** (seed, logical epoch, budgets, language version) on the immutable half; holder, lease and `journalHigh` on the status half |\n| the program | `program.<endpoint>.<runId>` record | the source the run was started from, verbatim, written once by the driver that pinned the run; what a resume reads and what a migration is measured against |\n| the step journal | `WFJ_<space>` stream, one subject per run | append-only, no age eviction, no Direct Get; every append fenced by the run subject's own sequence; takeover is replay-then-activate |\n| a checkpoint answer | `answer.<endpoint>.<token>.<answerId>` | the payload beside the one-use settle fact; the settle names the answer it accepted |\n| a notice | `notice.<endpoint>.<runId>.<addresseeId>.<noticeId>` | one bounded decision told to one agent, rendered ahead of its next turn |\n| a migration | `migration.<endpoint>.<runId>.<migrationId>` | the report and who applied it, keyed by the report's own digest |\n| the admission | `admission.v1.<endpoint>.<runId>` in `cotal_admission_<space>` | the caller the run was admitted for, its channel ceiling and its provenance; written once before the driver starts, and the store refuses a second write on the key |\n| a revocation | `revoked.v1.<endpoint>.<runId>` in the same store | who revoked the run and why; create-only, idempotent, permanent at the broker, read by every host before its next channel effect |\n\nThe driver writes `journalHigh` at activation and again after each journal append, before the\nprogram acts on the entry. A successor whose replay ends below it refuses the run with\n`RunJournalTailTruncated`. That holds for records appended since the last activation too.\n\nA run's **driver** connects on a `run-driver` credential minted for one run and takeover\nattempt. It can append to its journal, use its replay durable, and write its own `run`, `program`,\n`notice` and `migration` records. It has no store point reads, checkpoint writes, chat consumers,\nor channel and membership registry grants.\n\nThe hosting process keeps a separate `run-mediator` connection for effects and reads. The driver\nreceives methods and data from that host; it never receives the mediator credential or connection.\nThe host checks the journal's current activation and step identity before dispatch, and checks\npause and wait authority again at each operation. Cancellation cleanup also admits losing steps\nnamed by a settled parent whose `cancel.issued` is still false. That permission allows cleanup;\nit cannot mint or rearm a cancelled pause. Wait acknowledgements consume host-held delivery\nreceipts. A recorded match can be reread only at its bound sequence and channel. A conclave's\nrecorded ownership flag must match its step-derived channel before registry writes or cleanup.\n\nThe mediator retains endpoint-wide checkpoint rights and stream-wide leader reads as trusted\nhost authority. Record reads exposed to the driver are restricted to its own run's keys. Reads\nthat decide writes remain leader-served. A read of the journal uses the run's filtered replay\ndurable, including the diagnostic for a journal with no run record. That durable is named after\nthe takeover, and an attempt reads it many times, so reads under one takeover run one at a time in\nthe hosting process and a replay removes a durable of its own name that an interrupted earlier read\nleft behind. A durable that survives a replay's own delete belongs to a reader the process cannot\naccount for, and reading its tail is refused. A drive handles that refusal as a takeover does: the\nreads behind its steps, and the diagnostic for a journal with no run record, replay up to three\ntimes before the refusal is raised. An operator read runs under a takeover minted for that read and\nreports the refusal on its first read.\n\nA served read uses a one-shot `run-operator` credential. An answer uses a read to find the open\npause, then a second credential pinned to that token for the answer and settlement.\n`cotal run --local` uses the same driver/mediator split. On an authenticated mesh it needs the\nlocally recorded space signer to mint both credentials; a single `--creds` file is refused.\nDirect library users supplying broker clients to `MeshHandler` are constructing a trusted effect\nhost. A hosted driver receives its closed effect interface instead.\n\nThis split confines broker credentials; it is not process isolation for injected host code.\nThe runtime and its effect host share the manager process. A run's channel reach is the admitted\nceiling ([SPEC \xA714.8](../SPEC.md#148-run-admission)): the starting caller's issued channel scope,\nrecorded once in `cotal_admission_<space>` under a per-run `run-admitter` credential the driver\nnever holds, and re-read by the host before every channel effect. Spawn and turn keep their own\ndelegated checks; `notify` writes agent-addressed notices and is not channel publication. Treat\n`run` as program-execution authority bounded by that ceiling, not as sandboxing of the program.\n\nA version-1 fork can replay its settled parent history through the host. Inherited checkpoint\nidentifiers carry no authority to read, rearm or claim the parent's pauses. New child effects use\nchild-derived identifiers. A fork is a new run and takes a new admission under the caller who\nforks it; the parent's ceiling is not inherited.\n\n\n## What ships today\n\nThe language, its validator, interpreter, simulator and dry run are `@cotal-ai/lang`\n(`packages/lang`), usable in-process with your own effect handler and with no broker: `validate(src)`,\nthen `run(src, { runId, handler })`, and `resume(src, journal, { runId, pins, handler })` to pick a\nrun up from its journal (the package README has the snippet, with `SimHandler` as the handler). That\nis the in-process route, yours to drive with your own handler; a run the driver starts executes on\nthe compiled engine, as the engine paragraph below says. The wire\nsubstrate of \xA714 (the `WFJ_<space>` stream, the five record kinds, the activation barrier, the\nper-run grants) is in `@cotal-ai/core`, and the run driver, journal store, migrate and fork are\n`@cotal-ai/runtime` (`implementations/runtime`). The migrate check is reachable as\n`cotal run migrate <runId> --local --file <program>`; committing a migration it judged admissible\nis not reachable from any surface yet. On the mesh handler, `sleep`, `checkpoint`,\n`wait(message(...))`, `wait(idle(...))`, `wait(down(...))`, `wait(replied(...))`, `notify`,\n`spawn`, `conclave`, `ask`, `monitor` and `turn` are durable.\n`spawn` is\nthe manager's spawn action submitted under the step's own identity: the goal binds under the step's\nrequest id, so a resumed run re-attaches to the same seat instead of allocating a second one, a\nfailed or refused spawn is catchable as L4002 with the manager's recorded reason, and a spawn on a\nrace branch that loses is despawned by the run's own cancellation sweep. A seat belongs to the run\nthat spawned it: when the run completes, it despawns every seat it spawned, including a race\nwinner's and one whose spawn failed while its process stayed up. A spawn marked `onFork: \"adopt\"`\nis the exception: a fork can share that seat and no run can see whether another still uses it, so\nthe seat stays up until you stop it with `cotal stop` once every run sharing it is done. A seat a\nmigration handed to a later spawn follows that spawn's policy, and it stays up if any spawn that held\nit was marked `onFork: \"adopt\"`, because a fork taken before the migration may still share it. In a\nspace with several managers, a despawn counts a seat as already gone only when the manager that\nallocated it says so. A run that\nfails or is released keeps its seats until a resume completes it or you stop them with\n`cotal stop`. Start a seat with `cotal spawn` when it should outlive any run. `permits` are the budgets\nthis host meters: `turns`, how many turns the run may dispatch to the agent, and `wallClock`, a\nduration from the spawn after which no turn is admitted. The turn that would exceed one is the\ncatchable L4001 (kind `permit-turns` or `permit-wall-clock`; a deadline the remaining wall clock\ncannot hold counts as exceeding it), an adopted run counts the turns its journal recorded, and a\nbudget the host has no meter for, such as `tokens` or `spend`, is refused at the spawn rather than\naccepted and ignored. `supervise` is the restart policy this host asks the manager to enforce:\n`restarts`, how many in-window process deaths may come back under the same handle, and `window`,\nthe duration those deaths are counted in (default `10m`). The manager restarts the process in\nplace under the same name, lifecycle uid, persona, worktree and permits; `monitor` does not fire\nfor a restart, and `wait(down)` fires only when the seat is gone for good. Spending the budget\nretires the seat, and the next `turn` is the catchable L4002. A policy this host cannot enforce\n(an unknown key, a user-mode seat, or a runtime that cannot respawn a name in place) is refused\nat the spawn rather than accepted and ignored. `events: false` is the workflow form of\n`cotal spawn --no-events`: the seat starts without its AG-UI event plane. A connector that\npublishes none, such as Hermes, needs it, because an omitted `events` arms the plane and the\nmanager refuses that connector at the spawn. A value that is not a boolean is refused at the\nspawn. `conclave` joins its\nmembers to a real channel as durable membership rows: the channel derives from the step's own\nrequest id when the program names none (a program-named channel is borrowed, never torn down, and\na membership that predates the conclave survives its close), each member handle resolves to its\nprincipal through the seat's own presence row (an absent member is catchable as L4002), and a\nconclave cancelled on a losing branch is released by the same cancellation sweep. `ask` parks one\ncheckpoint-plane pause per attempt, answered through `cotal run answer` as a checkpoint is, and\ntells the agent through the same relay `turn` uses: one relay per attempt under the attempt's own\ntoken, carrying the schema, the attempt count, the deadline and the previous refusal, which the\nseat's connector renders as the record wanted and the hosted command that answers it. The relay\nnames the ask's absolute deadline, so the manager stops serving an attempt at the instant its\npause expires and refuses a relay already past it. When that\nliteral command is run inside the managed seat, the CLI reuses the seat's lifecycle credential and\nissued caller identity rather than minting an operator instrument. The command\ndoes not take `--by`: the manager records the authenticated caller as the answerer. A spawned seat's\nbaseline credential carries only the self-targeted `run-answer` row, and the manager accepts it only\nfor the open ask or escalation relayed to that exact incarnation. It cannot answer another seat's\nask, an unrelayed checkpoint, another run, or start and resume commands. An ask addresses\nan agent the run spawned (anything else refuses before an attempt opens), a resumed attempt tells\nthe seat nothing twice, and a seat gone at the relay is L4002. On the pause itself:\nthe shorthand of the language reference \xA76.5 is enforced (an unreadable schema is L4022), a\nnon-conforming answer costs one attempt and its refusal reason is recorded on the entry for the\nanswerer to read, exhausted attempts (default one) are the catchable L4006, and so is the one\nabsolute deadline for the whole ask passing with no conforming record (its kind is `ask-deadline`),\nincluding one that passes before an attempt's pause is armed.\n`checkpoint` binds what it asks on its own entry, so `cotal run journal` prints the question under\nthe step key an answer is addressed by while the pause is open: the address alone left whoever was\nasked reading the source to find out what \"approve\" meant. The entry also records the open\nattempt's deadline and the `onExpiry` it was armed with, which `run journal --json` reports; what\nan expiry does is still decided from the program's source. An escalation's attempt records its own\ndeadline, one `timeout` after it opens, and a resumed attempt keeps the deadline it recorded. After a checkpoint or `ask` accepts an\nanswer, the journal prints that accepted answer's recorded value and attribution under the settled\nstep. A checkpoint's comes from its frozen result, and the journal never substitutes another filed\nanswer or invents fields that result does not hold. An `ask`'s result is the value alone, so its line\nis read from the answer record its last attempt's settle named, even when that value is a record with\nfields named like a checkpoint's. Amendments print on their own lines after it.\nAn `escalate` addressed to an agent this\nrun spawned is relayed to that seat through the same turn relay an `ask` uses, carrying the prompt\nand the token to answer under, held to the pause's own deadline; a `to` naming anyone else is a\nperson, and their pause stays the one anybody can answer, with the addressee recorded and rendered\nbeside the question.\n`monitor` registers interest in an agent, and the\nregistration is the journal entry itself, carrying the handle it registered: monitoring an agent\nthat is already dead succeeds, and the death is the wait's to observe. `wait(down(...))` observes\na monitored agent, and refuses one the run never performed `monitor` on. It reads the death off presence liveness, the\nsame witness a conclave join resolves members through: the value carries the handle, the reason\n(`lapsed` when nothing live holds the name any more, `superseded` when a live row holds it under\na different incarnation) and the time of observation. Presence liveness skips any value without a\nstring `card.id` and `card.name`, so a participant that publishes a malformed row under its own key\ncannot fail another run's wait, turn or conclave join. A superseded incarnation is down at once. A\nlapsed one is down only after its presence row has stayed gone for 30 seconds, because a seat whose\nconnector stalls past the row's 6-second TTL, under host load or across a reconnect, renews it under\nthe same incarnation and is still working. The 30 seconds count only across presence reads that\neach end within 6 seconds of the previous one starting, so a slow read or a run of failed reads, which\ncould hide a renewal, starts the count over. Even then the run asks the manager first: a seat whose\nprocess the manager still runs is alive however long its row has been gone, so the count starts over.\nOnly the manager that spawned the seat can report it gone, because another manager in the space knows\nonly its own seats, so the run asks again until that one answers. When it does not answer, the presence\nrow is the only witness and the lapse stands. The asking stops at the wait's timeout or cancellation,\nand at a turn's deadline, yield or cancellation, which then decide. A wait that begins\nafter the death resolves once that holds, and a timeout resolves null on one absolute deadline a\nresumed run re-attaches to.\n`turn` wakes one seat for one host turn through the manager as a pull-shaped relay: the run\nsubmits the turn under the step's own identity, the manager holds it as a goal pinned to the\nseat's incarnation, and the seat pulls it under its own reach ahead of its next host turn, so\nnothing is pushed into a session mid-thought. The payload the seat reads names the run and the\nstep and carries the rendered run context, plus any pending notices addressed to it, which the\nturn consumes. The seat yields through `cotal_yield` (`done`, `blocked`, or `handoff` with an\naddressee), and ending its host turn yields `done` for every turn it was shown. A `handoff` names\nanother seat the same run spawned: the next `turn` in the same scope to that seat records the\nlink, a handoff to a name the run never spawned is the catchable L4005, and one to a seat bound\nto a different worktree is L4004. The deadline elapsing before any yield is the catchable L4003:\nthe acceptance names the instant, the manager's goal-bound hold denies at it, and the run arms its\nown pause on that same instant, so either side outliving the other still converges on the same\nanswer. A seat that dies mid-turn is read off its own presence row by the run itself, with the\nsame 30-second confirmation for a lapsed row, and is the catchable L4002, and a death the manager\nmarked on the deadline terminal reads the same way. Two\nturns on one seat, from two branches or from two runs, reach it one at a time: the language\ndispatches the second when the first settles, and the manager shows a seat the oldest unsettled\nturn alone. On an auth mesh the relay needs no extra grant: every spawned seat's baseline\ncredential carries its own pull, yield, and caller-bound answer rows, the run driver's operator instrument carries the\nturn request and the `inspect` read that confirms a lapse, and the manager arms the deadline hold over its own serve grant and expires it\nitself once due. An accept the manager cannot finish is unwound to a failed terminal on the goal\nit bound, and a retry of that submission is refused naming the terminal rather than accepted a\nsecond time.\n`wait(replied(...))` observes those turns from another branch: a completed turn is a reply, and\nthe wait resolves with the observation record (the handle, the yield's status and note, the\nyield's own stamp). It reads as a level, the way `wait(down)` does: a reply that already exists\nresolves the wait at once, and two replies resolve to the latest by the yield's stamp. A denied\nor cancelled turn is never a reply, so an unanswered wait rides its own mediated timeout to\n`null`, and a handle the run never spawned or turned refuses loudly, since only this run's turns\nare observable. A turn the run itself ended without an accepted yield (its deadline, a\ncancellation, a refused handoff) is never a reply, whatever the seat yields to the relay later.\nA cancelled branch also withdraws what it relayed to a seat before its cancellation completes: its\nturn, its ask attempt or its escalation ends `cancelled` through the manager's reserved `cancel`\n(SPEC \xA713.6), so the seat is not shown it after the branch's scope settles and the next turn to\nthat seat does not wait behind it. Only the manager that accepted the relay holds it, so in a\nspace with more than one manager the run sends the cancel again while another manager refuses it,\nuntil the accepting one answers. A cancel the accepting manager refuses is sent again until it\nlands or the relay's deadline passes, so the branch's cancellation does not complete while the\nseat can still be shown the relay. The run reads that deadline from the relay's goal and retries\na failed read, since the relay may still be served until the deadline is known. A goal record\nthat can never yield a deadline, such as one that is not JSON, fails the branch's step at once,\nand so does a refusal that lasts past the deadline.\nA `spawn` may bind its agent to a **logical worktree** (`spawn(\"builder\", { worktree: \"wt-1\" })`):\nthe handle carries the id, and the run enforces the one rule the language states about it: two\nagents never share a worktree concurrently. The validator rejects the literal case up front\n(L3022: two branches of one concurrent scope spawning into one literal worktree, named branch\nfunctions included), and the runtime guards the rest, computed ids included: a spawn claims its\ntree before it submits, so a second spawn into a tree held by a live seat or by a spawn still\nbringing one up is the catchable L4008, a spawn that ends without a handle gives the tree back,\nand the tree is reusable the moment a holder's presence row is gone, so a discharged race loser\nor a crashed seat releases its tree with no bookkeeping. A spawn the endpoint refuses at accept\nis the catchable L4000 (L4001 when the refusal is the endpoint's seat capacity), and one whose\nseat never came up is L4002. A refusal that states the command did not run is answered\nbefore it gets that far. In a space served by more than one manager the resolve and the invoke\nare separate trips through the same anycast queue, so a run's call can reach an instance it did\nnot resolve against, and that instance refuses ahead of any effect. The run drops its resolved\nhandle, re-describes and re-issues, for a bounded number of attempts; after them the refusal\nsurfaces as the effect's own failure and still states that nothing ran. A spawn that names a\n`placement` addresses one instance by name, so a refusal from it is that incarnation answering\nabout itself and is never re-issued. When the spawn also names `cwd`, that manager resolves the\nexisting absolute directory to its canonical host path before accepting the spawn. A missing,\nrelative or non-directory path refuses with no seat and never falls back to the manager workspace.\nOne hosted run may name one placement instance; a program naming several is refused instead of\nwidening one run credential across hosts. Placement is accepted only from an object-literal spawn\noption bag whose `endpoint` and `instanceId` are string literals. A computed option bag, a computed\nplacement field, or a spread in either object refuses at `run-start`. The option argument may be\nabsent, and an object-literal bag without placement is accepted.\nA turn handoff across worktrees is the L4004 described above. Recovery keeps these honest: a resumed run\nreseeds its roster, holders and handoff memos from its own journal, and the driver re-issues any\nrecorded-but-undischarged cancellation at adoption, before the engine performs a new step, so a\nloser a crash left alive does not keep its seat or its tree while the resumed run works on. The\nsame sweep withdraws a cancelled branch's relays and undelivered notices: a notice waits on the run for its\naddressee's next turn, so a decision the run cancelled would otherwise arrive at an agent with\nnothing to distinguish it from one that stood.\n\nEvery effect the language defines performs on the mesh handler; nothing is refused as\nnot-yet-durable any more. The operator surface over the driver is `cotal run`; the section above has the verbs.\n\n**Two engines, and which one runs your program.** The tree-walker is language version `1` and the\ncompiled engine is version `2`, two languages rather than two speeds of one (`spec/cotal-lang.md`\n\xA78.4 lists what differs). The driver hosts both: **every run a driver starts is stamped `2` and\nexecuted by the compiled engine**. The program runs in its own locked-down worker thread with\nnothing in its global scope, while the effects and the durable journal stay in the driver's process,\nbridged over a message port. No socket or credential enters the isolate holding the program,\nand **every version-`1` record keeps replaying on the walker**, which is the walker's job. On either\nengine the driver bounds an effect's `ok` result at the broker's `max_payload` less 4096 bytes: a\nlarger result is refused ahead of the settling append (**L5006**), the step stays pending, and the\nrun is released. The driver serves a declared set of versions, and a record whose version it does\nnot serve is refused by name (**L5023**) with the run left untouched, instead of being replayed by\nwhichever engine happens to be present. Records do not cross between versions in either\ndirection; the repair is to resume on the recorded version, or to fork.\n\n**The engine needs node 22 or newer** and refuses below it as `EngineUnavailable`, which is an\nimplementation limit and not a language error: it carries no `L` code, so there is nothing to look\nup in the catalog. It is a floor rather than a warning because the engine's frame plumbing rests on\n`AsyncLocalStorage`, and 22 is the lowest node it has been measured on. The walker has no such floor.\n"
62746
+ "body": "# Workflow runs\n\n> **Concept** (informative) \xB7 **For:** people writing a durable multi-agent workflow, and implementers hosting one \xB7 **Normative:** [SPEC \xA714](../SPEC.md#14-workflow-runs-v05) and the language reference [`spec/cotal-lang.md`](../spec/cotal-lang.md)\n\nA **workflow run** is a program that coordinates agents over hours or days and survives the\nprocess that started it. The program is written in **Cotal Lang**, a small subset of JavaScript in\nwhich every interaction with the world is one of a dozen **effects** (`spawn`, `turn`, `ask`,\n`checkpoint`, `sleep`, `wait`, `notify`, `monitor`, and the four concurrency scopes) and everything\nelse is ordinary, pure JavaScript. Every effect is written into the run's **step journal** before\nit is performed and settled after, keyed by where in the program it happened rather than by when,\nso a run that dies is resumed on any host by **re-running the program from the top** with recorded\neffects returning their recorded results. Nothing about the interpreter is ever serialized: the\njournal and the program are the whole state.\n\n## A first program\n\n```js\nconst planner = await spawn(\"planner\")\nconst builder = await spawn(\"builder\", { worktree: \"wt-1\" })\n\nconst plan = await ask(planner, { name: \"plan\", schema: { steps: \"array\" } })\nconst ok = await checkpoint(\"approve-plan\", \"Approve the plan?\", { timeout: \"4h\", onExpiry: \"proceed\" })\nif (ok.status !== \"resolved\") {\n await notify([planner], { decision: \"approve-plan\", outcome: \"expired\" })\n}\n\nconst r = await turn(builder, { name: \"build\", deadline: \"30m\" })\nif (r.status === \"blocked\") {\n await turn(planner, { name: \"unblock\" })\n}\n\nconst outcome = await race({\n reply: () => wait(replied(builder), { timeout: \"20m\" }),\n giveUp: () => sleep(\"1h\"),\n}, { name: \"await-or-move-on\" })\nlog(\"outcome\", outcome.index)\n```\n\nRead it as the flowchart it is. `spawn` brings agents in; `ask` is the narrow case where the\nprogram itself needs a value (`schema` is a record the program hands the handler unchanged; the\nlanguage hashes it and gives it no meaning, and the handlers in this repository enforce it as the\nshorthand of the language reference \xA76.5);\n`checkpoint` is a durable pause a human resolves from anywhere, raced against a durable timer; `turn`\nwakes an agent for one turn and returns how it yielded; `race` runs two branches and keeps the one\nwhose recorded clock is earliest. Agents talk to each other in channels as they always do; the\nprogram never speaks in a channel, and the one thing it can put in front of an agent (`notify`) is a\nbounded decision record, not prose.\n\n## The mental model\n\n- **Pure code is JavaScript.** Loops, records, arrays, closures, template literals, destructuring,\n `try`/`catch`, arithmetic, `switch`, compound assignment, optional chaining, spread and rest: what\n you would write anyway, with the parts that hide effects or make meaning depend on the host removed\n (`class`, `this`, `new`, `for...in`, `==`, labels, regex literals, `Math`/`Date`/`JSON`, promises,\n generators). Every refusal names its code and the edit that fixes it. The builtins are a short list\n (`keys`, `map`, `sort`, `json.stringify`, `now()`, `random()`), and arrays, strings and numbers\n answer their usual methods (`xs.map`, `s.trim()`, `n.toFixed()`) and nothing outside that table.\n Records and arrays you build are yours to change until they cross an effect boundary; a member you\n do not own, a host prototype, or a value another branch built is refused with a code, never a\n surprise.\n- **Every effect is journalled and hashed.** A step is keyed `(scope path, kind, name, occurrence)`\n and its inputs are hashed. Reorder your program, add a step, rename a variable: recorded steps\n still match. Change what a step asks (a checkpoint's prompt, a sleep's duration, a turn's\n deadline) and the resume stops with a **divergence** naming the step, rather than replaying an\n answer to a question the program no longer asks.\n- **Concurrency is visible.** `parallel`, `race`, `fanOut` and `conclave` are the only ways to do\n two things at once, each branch gets its own journal namespace, and the scope writes its own\n entry saying how it settled: which arm won a race is a recorded fact, decided by the arms'\n recorded clocks and declaration order, never by a scheduler. A failure the program itself caused\n inside the scope settles the entry under its own catalog code (`fanOut` without a stable key is\n `L3021`, kind `runtime`), so a resume reads the same code the live run threw; a plain failure\n from the handler records the generic `L4000` `scope-fault`. A value the program throws inside a\n branch is recorded with that fault, so `catch` receives the same value on resume, and in a\n migration's dry walk, as it did live.\n A branch may not write to anything declared outside it; return the value and read it out of the\n scope's result.\n- **Time and randomness are tamed.** `now()` is the branch's run clock, the end of the last effect\n it awaited; `random()` is a seeded stream derived per scope. Both replay identically.\n- **Values freeze at the boundary.** What crossed into or out of an effect is what the journal\n recorded, and it cannot change afterwards; build a new value.\n- **The journal is the debugger.** Every entry carries its key, its inputs' hash, its outcome and\n its timing, and every error is in the program's own coordinates. A run can be **simulated** with a\n scripted handler and **dry-run** to a plan before it touches an agent. The simulator is\n discrete-event: timed effects park at their wake times and are delivered in wake order on one\n virtual clock, so concurrent branches accumulate the durations they wrote and a simulated `race`\n is decided by the same rule a live handler produces (least recorded clock, ties by declaration\n order). A `sleep(\"1m\")` arm beats a `sleep(\"1h\")` arm whatever their declaration order. Behind the\n worker bridge the simulator delivers its next wake only after the thread reports it has reacted\n to the last one, so a bridged simulation settles a race the same way, also when the dry run's\n recorder wraps the simulator.\n\nFull rules, with every code: [`spec/cotal-lang.md`](../spec/cotal-lang.md).\n\n## Continuing a run\n\n**Resume** is re-execution: the driver replays the journal, the program runs from the top, recorded\nsteps return instantly, and the first unrecorded step is performed live. It refuses a journal that\nbelongs to another run, a pin that differs from the recorded ones, and a different language version.\n\nA failed journal entry replays its error, while a pending entry lets the handler recover the\nexternal work it bound before the interruption.\n\nA step that writes to a far side that honors no idempotency key (posting a comment, sending a mail)\nbelongs in `once`. A resume that finds a step inside `once` begun and never settled does not\ndispatch it again: it opens a hold, a checkpoint under a token derived from the step's recorded\nrequest id, whose prompt names that id. Answer it with\n`cotal run answer <run> <step-key> --value <json>`, and the value becomes the step's result. An\nexpired hold fails the step with the catchable L4027. Only `ask` runs inside `once`, so wrap just\nthe step that writes: a host restart while it is in flight costs a settle.\n\n```js\nconst publisher = await spawn(\"publisher\")\nconst res = await once(async () => {\n return await ask(publisher, { name: \"publish\", schema: { commentId: \"number\" } })\n}, { name: \"publish-360\" })\n```\n\n**Migrate** moves a run onto edited source. A dry walk of the new program over the recorded journal\nfinds every recorded step the edit changed (a divergence) and every one it no longer reaches (an\norphan), and the orphan table says what each means: a removed `sleep` is nothing, a removed `turn`\nalready happened, a removed `spawn` is a live agent you must adopt or release, a removed resolved\n`checkpoint` is a human decision you must explicitly discard. The decision is filed as a\n`migration` record with the actor's name on it. An adopted seat (`--adopt <name>#<uid>`) goes to\nthe edited program's next `spawn` of that persona, which returns the recorded handle and mints\nnothing, so the agent keeps its identity, its worktree and its turn history across the edit. A\nreleased seat (`--release <name>#<uid>`) is despawned when the migration commits, through the same\ndischarge a cancelled branch's seat leaves by, so the record never claims a release nothing did.\nThe spawn that adopts a seat binds the orphaned spawn's goal as its own, so a resume of that step\nreads the same seat back and a cancellation of it despawns the seat it holds.\n\n**Fork** starts a new run from a named step of an old one, copying the prefix under the parent's\npins (seed included, so the copied history's pure draws are the same draws). The child is a new run\nunder a new id whose record names the parent and the cut step (`forkedFrom`); the parent is\nuntouched. A spawn inside the copied prefix is honoured by its `onFork`: `\"adopt\"` copies it, and\nthe child shares the parent's agent (the manager shows that seat one turn at a time across both\nruns); `\"respawn\"`, the default, would mint a fresh identity the copied turns do not address, so\nthis host refuses that cut (L5019) rather than rewriting the parent's history.\n\nFork planning and migration inspection use the recorded language version. Version-1 history uses\nthe interpreter. Version-2 history is inspected inside a locked-down worker with a read-only\njournal, without a live effect handler or durable store. Inspection stops before the fork's cut\nstep or any effect that needs new work. Program catch and finally blocks cannot extend the cut.\nThe recorded pins are preserved.\n\n## From an agent session\n\nFresh `cotal setup` defaults declare `capabilities: [spawn, run]`. On an authenticated mesh,\nthat exposes `cotal_run` alongside the teammate tools. The selected manager must support execution.\nRead `cotal_docs` pages `lang-card` and `workflows`, then try:\n\n```json\n{\n \"verb\": \"start\",\n \"source\": \"await sleep(\\\"1s\\\", { name: \\\"first-run\\\" });\",\n \"file\": \"first-run.cotal.js\"\n}\n```\n\nPass this object to `cotal_run`. `source` contains the program; `file` only labels diagnostics\nand reads nothing from disk. The response returns a run ID before execution finishes. Call\n`cotal_run` with `verb: \"status\"` and that `runId` to inspect the state and step journal.\nA completed timer records its sleep step as `ok`.\n\n### If `cotal_run` is missing\n\n1. Call `cotal_orientation` and check the connector version, capabilities and tool list.\n Upgrade an older installation using the [upgrade guide](https://github.com/Cotal-AI/Cotal/blob/main/docs/UPGRADING.md).\n2. Have the operator add `run` to the persona's existing `capabilities` list, for example\n `capabilities: [spawn, run]`. `spawn` alone does not expose `cotal_run`. Setup leaves existing\n personas unchanged except for its [byte-exact legacy migration](getting-started.md).\n Peer persona-definition tools cannot grant capabilities.\n3. Relaunch the agent through the manager from the updated persona so it receives newly issued\n credentials and a fresh connector configuration. Editing the file or reconnecting with the\n old credential does not grant new broker permissions. If the launch sets `COTAL_CAPABILITIES`,\n update that override too; it takes precedence over the file.\n4. Check `cotal_orientation` again, then call `cotal_run` with `verb: \"ps\"` before starting work.\n\nTool visibility alone does not establish execution support. Hosted runs require issued caller\nauthority. Static authentication issues it to its credentials. On a user-auth mesh, a registered\nparticipant manager hosts authorized runs. A managed agent invokes `cotal_run` through its own\nlauncher-provided bearer command and immutable manager instance selection. Its current managed\ngrant and its direct parent's current grant must both carry `run`. A removed grant, stale lifecycle\nor wrong manager selection refuses rather than borrowing a signed-in user's authority.\n\nOpen meshes may expose the tool but refuse hosted runs. A local user-mode host without remote\nrun authority and an `execution: \"none\"` control manager also refuse the family. A legacy\ncredential without issued authority must be replaced through the current issuance path.\n\n## Operating a run\n\nThe manager hosts runs. `cotal run start` hands the program to the manager of the resolved mesh\n(the usual `--space` / `--server` / `--creds` flags), which validates it, mints the run id, drives\nit in its own process, and answers with the id once the run is recorded. The terminal is free the\nmoment the id prints; the run continues on the manager through every pause, and a manager restart\ntakes back every run it had recorded running, from the journal, under the next epoch. `resume`\nnames a run the manager recorded and is refused while the manager is already driving it. `ps` and\n`journal` read; `answer` resolves an open checkpoint, or an open `ask` attempt, from any terminal\nor agent that holds the `run` capability.\n\n`journal` prints an open pause's question under its step. Once a checkpoint or `ask` settles with an\naccepted answer, it instead prints the answer value as JSON, who answered, the artifact when one was\ncited, the recorded time, and the accepted answer id. Expired pauses and ordinary steps print no\nanswer line.\n\nA settled step is never answered twice, so a participant who changes their mind uses `amend`. It\nfiles a new answer beside the accepted one, naming the answer it supersedes, and `journal` prints\neach amendment under the step as an `amended` line, in the order the store committed them. The last\nline is the current position, whatever clock each amender's `at` came from. The pause stays settled\nand the run keeps the answer it acted on. A step that is still open, or that settled with no answer,\nrefuses an amend. The manager records the amender from the credential, as for an answer, and a\nspawned seat may amend only an answer recorded under its own name.\n\n```bash\ncotal run start --file build.cotal.js # the manager starts it; the minted id is printed\ncotal run ps # list run records: state, holder, lineage\ncotal run journal run-3f2a90c41b7e0d5a6c884e19b02df4a1 # print the durable step journal\ncotal run resume run-3f2a90c41b7e0d5a6c884e19b02df4a1 # the manager takes the run back\ncotal run answer run-3f2a90c41b7e0d5a6c884e19b02df4a1 \"/checkpoint:approve#0\" --value '\"yes\"'\ncotal run amend run-3f2a90c41b7e0d5a6c884e19b02df4a1 \"/checkpoint:approve#0\" --value '\"no\"' # record a changed position\ncotal run migrate run-3f2a90c41b7e0d5a6c884e19b02df4a1 --local --file build-v2.cotal.js # check an edited program against the journal\n```\n\nA program that does not validate is refused before anything is recorded, with every problem in the\nanswer as the validator would print it. The driver records the program beside the run, so `resume`\ntakes the run id alone and the manager reads the source back; an edited program is a `migrate` or a\n`fork`, never a resume. `cotal run migrate <runId> --local --file <program>` is that check: it\nreplays the run's journal and walks the edited program over it, prints whether the migration is\nadmissible, how many journal rows the walk accounted for, every orphaned step with its verdict and\ncode, and exits 0 on admissible and non-zero on not. It reads only, under the same credential\n`journal` reads on, and the commit side is not reachable yet: the report itself says what a commit\nwould file and that this invocation filed nothing. An answer is recorded under the answerer the\nmanager knows from the caller's credential: a managed agent by its name, anyone else by their\nprincipal. The request\ncarries no name. An agent with `capabilities: [run]` has the same five verbs as the `cotal_run`\ntool ([MCP tools](mcp-tools.md)), so a program can be written and started from inside a session.\nA `start` or `resume` answers once the run's record is written, within a bounded wait; a manager\nthat is still taking back a predecessor's runs at boot refuses both with `unavailable`, and a\nretry a moment later is the whole remedy.\n\n`--local` drives the run in this process instead: `start`, `resume` and `answer` exit when the\ndrive settles, `--by <who>` names the answerer, and `cotal run resume <runId> --local --file\n<program>` is how a run with no recorded program is continued. On a static mesh the local drive\nmints the run's own credential from the folder's trust material, so it runs from the mesh's\nproject folder. A local start also names the run's channel ceiling itself:\n`--admit-read <channels> --admit-publish <channels>`, comma-separated patterns or `none`, both\nrequired. The record it writes says an operator admitted the run and why, and the host checks\nit the same way it checks a hosted admission. A registered remote user-auth manager can host a\nrun through its issuing host. The host resolves a versioned caller against live issuance,\nadmits the run, and signs only the run's fixed driver, mediator and one-shot operator credentials\nfor manager-held nkeys. Renewal checks the activated attempt; the manager holds no signer.\nLocal user-auth runs remain unavailable because a user bearer holds no run rows. An open mesh\nhosts none either, since it issues no caller authority to admit a run under.\n\nA logged-in user starts runs on that remote manager with `cotal run start`. The auth callout issues\nthe user's manager connection against the user's actor-ledger row, the CLI reads the generation\nback from the connection's accepted row, and every `run` verb rides the versioned rail. The issuing\nhost admits a run only for the owner who registered the manager, and on every resume and answer it\nchecks that owner and that the caller's issuance is still live. It also watches the start, resume\nand answer requests on the broker itself, and admits or issues for one the manager forwards only if\nit saw that request, and only once. A resume or answer gets issuance only for the run, step, endpoint\nand amendment that request named. An answer's\ncredential reaches one pause, and the host reads which one off the run's own journal, for a run\nadmitted on that manager's instance. A request bound to another manager instance or epoch gets nothing, and so does one whose class or pinned contract is not the one the manager registered. Another user's start, answer or resume is refused, and so is a revoked actor's.\nSuch a run spawns, turns and despawns agents owned by that user. A spawn has the reach of the user\nwho started the run, as their actor-ledger row reads at that moment, and the host enrolls each agent\nthrough its managed-agent enrollment. A spawn may be placed on the manager that hosts the run and on\nno other instance. [User-auth run start](design/user-auth-run-start.md)\nrecords the path.\n\nA hosted run is **admitted** under the caller that started it. The caller's credential is an\nissuance ([identity and auth](identity-and-auth.md#issued-authority)): its requests ride a\nversioned rail that carries the credential's generation, and the manager resolves that\ngeneration's recorded permission ceiling and writes it beside the run before the driver starts.\nThat ceiling, the caller's own channel scope as it was issued, is what the run may read and post\nin channels; the manager's own reach never stands in for it. A request from a credential minted\nwithout an issuance is refused with `permission-denied` and a detail naming the caller. A run\nwhose caller had no channels can still sleep, checkpoint and turn agents; its `wait` on a channel\nis refused at the effect.\n\n`cotal run revoke <runId> --local --by <who> --reason <text>` writes the run's revocation marker\nfrom the project folder. An empty `--by` or `--reason` is refused before anything is written. The\nadmission itself is never rewritten. Every host reads the marker\nbefore its next channel effect, so an open `wait` refuses at its next poll, and no resume,\ntakeover or manager restart continues the run. Revoking twice is not an error, and the first\nreason stands. A run whose admission is missing or revoked is left parked by the manager's boot\nreconcile, named in its log.\n\n`run ps`, hosted or `--local`, reads the marker beside each run record and prints `revoked` for a\nrun that carries one, whatever state the record itself holds, with the revoker and the reason\nunder the table. The record is display only here: a revoke writes no terminal state, because no host drove\nthe run to one and the journal owns the facts. A marker the listing cannot read, whether the store\nis unreachable or the marker has a version or shape it does not know, prints `unchecked` in the\n`STATE` column. The reason and the state the record carries go to stderr, and the command exits 1\nonce every row is printed. The hosted `run-ps` rows carry the marker as `revoked` (`by` and\n`reason`) or a failed read as `revocationUnreadable`, beside the record's own `state`.\n\nA run whose step was refused (L5016) stays held; a\nresume on a host that can perform the step performs it live and continues from there.\n`journal` prints what an open pause asks beneath its step key, which is the address `answer` takes\nback. Checkpoint expiry rides the mediated timer writer, which the delivery daemon pumps on a live\nmesh; on a bare broker a pause still resolves, it just cannot expire.\n\n## What is on the wire\n\nThe run's wire footprint is [SPEC \xA714](../SPEC.md#14-workflow-runs-v05):\n\n| Thing | Where | What it is |\n| --- | --- | --- |\n| the run | `run.<endpoint>.<runId>` record | the resolved **pins** (seed, logical epoch, budgets, language version) on the immutable half; holder, lease and `journalHigh` on the status half |\n| the program | `program.<endpoint>.<runId>` record | the source the run was started from, verbatim, written once by the driver that pinned the run; what a resume reads and what a migration is measured against |\n| the step journal | `WFJ_<space>` stream, one subject per run | append-only, no age eviction, no Direct Get; every append fenced by the run subject's own sequence; takeover is replay-then-activate |\n| a checkpoint answer | `answer.<endpoint>.<token>.<answerId>` | the payload beside the one-use settle fact; the settle names the answer it accepted |\n| a notice | `notice.<endpoint>.<runId>.<addresseeId>.<noticeId>` | one bounded decision told to one agent, rendered ahead of its next turn |\n| a migration | `migration.<endpoint>.<runId>.<migrationId>` | the report and who applied it, keyed by the report's own digest |\n| the admission | `admission.v1.<endpoint>.<runId>` in `cotal_admission_<space>` | the caller the run was admitted for, its channel ceiling and its provenance; written once before the driver starts, and the store refuses a second write on the key |\n| a revocation | `revoked.v1.<endpoint>.<runId>` in the same store | who revoked the run and why; create-only, idempotent, permanent at the broker, read by every host before its next channel effect |\n\nThe driver writes `journalHigh` at activation and again after each journal append, before the\nprogram acts on the entry. A successor whose replay ends below it refuses the run with\n`RunJournalTailTruncated`. That holds for records appended since the last activation too.\n\nA run's **driver** connects on a `run-driver` credential minted for one run and takeover\nattempt. It can append to its journal, use its replay durable, and write its own `run`, `program`,\n`notice` and `migration` records. It has no store point reads, checkpoint writes, chat consumers,\nor channel and membership registry grants.\n\nThe hosting process keeps a separate `run-mediator` connection for effects and reads. The driver\nreceives methods and data from that host; it never receives the mediator credential or connection.\nThe host checks the journal's current activation and step identity before dispatch, and checks\npause and wait authority again at each operation. Cancellation cleanup also admits losing steps\nnamed by a settled parent whose `cancel.issued` is still false. That permission allows cleanup;\nit cannot mint or rearm a cancelled pause. Wait acknowledgements consume host-held delivery\nreceipts. A recorded match can be reread only at its bound sequence and channel. A conclave's\nrecorded ownership flag must match its step-derived channel before registry writes or cleanup.\n\nThe mediator retains endpoint-wide checkpoint rights and stream-wide leader reads as trusted\nhost authority. Record reads exposed to the driver are restricted to its own run's keys. Reads\nthat decide writes remain leader-served. A read of the journal uses the run's filtered replay\ndurable, including the diagnostic for a journal with no run record. That durable is named after\nthe takeover, and an attempt reads it many times, so reads under one takeover run one at a time in\nthe hosting process and a replay removes a durable of its own name that an interrupted earlier read\nleft behind. A durable that survives a replay's own delete belongs to a reader the process cannot\naccount for, and reading its tail is refused. A drive handles that refusal as a takeover does: the\nreads behind its steps, and the diagnostic for a journal with no run record, replay up to three\ntimes before the refusal is raised. An operator read runs under a takeover minted for that read and\nreports the refusal on its first read.\n\nA served read uses a one-shot `run-operator` credential. An answer uses a read to find the open\npause, then a second credential pinned to that token for the answer and settlement.\n`cotal run --local` uses the same driver/mediator split. On an authenticated mesh it needs the\nlocally recorded space signer to mint both credentials; a single `--creds` file is refused.\nDirect library users supplying broker clients to `MeshHandler` are constructing a trusted effect\nhost. A hosted driver receives its closed effect interface instead.\n\nThis split confines broker credentials; it is not process isolation for injected host code.\nThe runtime and its effect host share the manager process. A run's channel reach is the admitted\nceiling ([SPEC \xA714.8](../SPEC.md#148-run-admission)): the starting caller's issued channel scope,\nrecorded once in `cotal_admission_<space>` under a per-run `run-admitter` credential the driver\nnever holds, and re-read by the host before every channel effect. Spawn and turn keep their own\ndelegated checks; `notify` writes agent-addressed notices and is not channel publication. Treat\n`run` as program-execution authority bounded by that ceiling, not as sandboxing of the program.\n\nA version-1 fork can replay its settled parent history through the host. Inherited checkpoint\nidentifiers carry no authority to read, rearm or claim the parent's pauses. New child effects use\nchild-derived identifiers. A fork is a new run and takes a new admission under the caller who\nforks it; the parent's ceiling is not inherited.\n\n\n## What ships today\n\nThe language, its validator, interpreter, simulator and dry run are `@cotal-ai/lang`\n(`packages/lang`), usable in-process with your own effect handler and with no broker: `validate(src)`,\nthen `run(src, { runId, handler })`, and `resume(src, journal, { runId, pins, handler })` to pick a\nrun up from its journal (the package README has the snippet, with `SimHandler` as the handler). That\nis the in-process route, yours to drive with your own handler; a run the driver starts executes on\nthe compiled engine, as the engine paragraph below says. The wire\nsubstrate of \xA714 (the `WFJ_<space>` stream, the five record kinds, the activation barrier, the\nper-run grants) is in `@cotal-ai/core`, and the run driver, journal store, migrate and fork are\n`@cotal-ai/runtime` (`implementations/runtime`). The migrate check is reachable as\n`cotal run migrate <runId> --local --file <program>`; committing a migration it judged admissible\nis not reachable from any surface yet. On the mesh handler, `sleep`, `checkpoint`,\n`wait(message(...))`, `wait(idle(...))`, `wait(down(...))`, `wait(replied(...))`, `notify`,\n`spawn`, `conclave`, `ask`, `monitor` and `turn` are durable.\n`spawn` is\nthe manager's spawn action submitted under the step's own identity: the goal binds under the step's\nrequest id, so a resumed run re-attaches to the same seat instead of allocating a second one, a\nfailed or refused spawn is catchable as L4002 with the manager's recorded reason, and a spawn on a\nrace branch that loses is despawned by the run's own cancellation sweep. A seat belongs to the run\nthat spawned it: when the run completes, it despawns every seat it spawned, including a race\nwinner's and one whose spawn failed while its process stayed up. A spawn marked `onFork: \"adopt\"`\nis the exception: a fork can share that seat and no run can see whether another still uses it, so\nthe seat stays up until you stop it with `cotal stop` once every run sharing it is done. A seat a\nmigration handed to a later spawn follows that spawn's policy, and it stays up if any spawn that held\nit was marked `onFork: \"adopt\"`, because a fork taken before the migration may still share it. In a\nspace with several managers, a despawn counts a seat as already gone only when the manager that\nallocated it says so. A run that\nfails or is released keeps its seats until a resume completes it or you stop them with\n`cotal stop`. Start a seat with `cotal spawn` when it should outlive any run. `permits` are the budgets\nthis host meters: `turns`, how many turns the run may dispatch to the agent, and `wallClock`, a\nduration from the spawn after which no turn is admitted. The turn that would exceed one is the\ncatchable L4001 (kind `permit-turns` or `permit-wall-clock`; a deadline the remaining wall clock\ncannot hold counts as exceeding it), an adopted run counts the turns its journal recorded, and a\nbudget the host has no meter for, such as `tokens` or `spend`, is refused at the spawn rather than\naccepted and ignored. `supervise` is the restart policy this host asks the manager to enforce:\n`restarts`, how many in-window process deaths may come back under the same handle, and `window`,\nthe duration those deaths are counted in (default `10m`). The manager restarts the process in\nplace under the same name, lifecycle uid, persona, worktree and permits; `monitor` does not fire\nfor a restart, and `wait(down)` fires only when the seat is gone for good. Spending the budget\nretires the seat, and the next `turn` is the catchable L4002. A policy this host cannot enforce\n(an unknown key, a user-mode seat, or a runtime that cannot respawn a name in place) is refused\nat the spawn rather than accepted and ignored. `events: false` is the workflow form of\n`cotal spawn --no-events`: the seat starts without its AG-UI event plane. A connector that\npublishes none, such as Hermes, needs it, because an omitted `events` arms the plane and the\nmanager refuses that connector at the spawn. A value that is not a boolean is refused at the\nspawn. `conclave` joins its\nmembers to a real channel as durable membership rows: the channel derives from the step's own\nrequest id when the program names none (a program-named channel is borrowed, never torn down, and\na membership that predates the conclave survives its close), each member handle resolves to its\nprincipal through the seat's own presence row (an absent member is catchable as L4002), and a\nconclave cancelled on a losing branch is released by the same cancellation sweep. `ask` parks one\ncheckpoint-plane pause per attempt, answered through `cotal run answer` as a checkpoint is, and\ntells the agent through the same relay `turn` uses: one relay per attempt under the attempt's own\ntoken, carrying the schema, the attempt count, the deadline and the previous refusal, which the\nseat's connector renders as the record wanted and the hosted command that answers it. The relay\nnames the ask's absolute deadline, so the manager stops serving an attempt at the instant its\npause expires and refuses a relay already past it. When that\nliteral command is run inside the managed seat, the CLI reuses the seat's lifecycle credential and\nissued caller identity rather than minting an operator instrument. The command\ndoes not take `--by`: the manager records the authenticated caller as the answerer. A spawned seat's\nbaseline credential carries only the self-targeted `run-answer` row, and the manager accepts it only\nfor the open ask or escalation relayed to that exact incarnation. It cannot answer another seat's\nask, an unrelayed checkpoint, another run, or start and resume commands. An ask addresses\nan agent the run spawned (anything else refuses before an attempt opens), a resumed attempt tells\nthe seat nothing twice, and a seat gone at the relay is L4002. On the pause itself:\nthe shorthand of the language reference \xA76.5 is enforced (an unreadable schema is L4022), a\nnon-conforming answer costs one attempt and its refusal reason is recorded on the entry for the\nanswerer to read, exhausted attempts (default one) are the catchable L4006, and so is the one\nabsolute deadline for the whole ask passing with no conforming record (its kind is `ask-deadline`),\nincluding one that passes before an attempt's pause is armed.\n`checkpoint` binds what it asks on its own entry, so `cotal run journal` prints the question under\nthe step key an answer is addressed by while the pause is open: the address alone left whoever was\nasked reading the source to find out what \"approve\" meant. The entry also records the open\nattempt's deadline and the `onExpiry` it was armed with, which `run journal --json` reports; what\nan expiry does is still decided from the program's source. An escalation's attempt records its own\ndeadline, one `timeout` after it opens, and a resumed attempt keeps the deadline it recorded. After a checkpoint or `ask` accepts an\nanswer, the journal prints that accepted answer's recorded value and attribution under the settled\nstep. A checkpoint's comes from its frozen result, and the journal never substitutes another filed\nanswer or invents fields that result does not hold. An `ask`'s result is the value alone, so its line\nis read from the answer record its last attempt's settle named, even when that value is a record with\nfields named like a checkpoint's. Amendments print on their own lines after it.\nAn `escalate` addressed to an agent this\nrun spawned is relayed to that seat through the same turn relay an `ask` uses, carrying the prompt\nand the token to answer under, held to the pause's own deadline; a `to` naming anyone else is a\nperson, and their pause stays the one anybody can answer, with the addressee recorded and rendered\nbeside the question.\n`monitor` registers interest in an agent, and the\nregistration is the journal entry itself, carrying the handle it registered: monitoring an agent\nthat is already dead succeeds, and the death is the wait's to observe. `wait(down(...))` observes\na monitored agent, and refuses one the run never performed `monitor` on. It reads the death off presence liveness, the\nsame witness a conclave join resolves members through: the value carries the handle, the reason\n(`lapsed` when nothing live holds the name any more, `superseded` when a live row holds it under\na different incarnation) and the time of observation. Presence liveness skips any value without a\nstring `card.id` and `card.name`, so a participant that publishes a malformed row under its own key\ncannot fail another run's wait, turn or conclave join. A superseded incarnation is down at once. A\nlapsed one is down only after its presence row has stayed gone for 30 seconds, because a seat whose\nconnector stalls past the row's 6-second TTL, under host load or across a reconnect, renews it under\nthe same incarnation and is still working. The 30 seconds count only across presence reads that\neach end within 6 seconds of the previous one starting, so a slow read or a run of failed reads, which\ncould hide a renewal, starts the count over. Even then the run asks the manager first: a seat whose\nprocess the manager still runs is alive however long its row has been gone, so the count starts over.\nOnly the manager that spawned the seat can report it gone, because another manager in the space knows\nonly its own seats, so the run asks again until that one answers. When it does not answer, the presence\nrow is the only witness and the lapse stands. The asking stops at the wait's timeout or cancellation,\nand at a turn's deadline, yield or cancellation, which then decide. A wait that begins\nafter the death resolves once that holds, and a timeout resolves null on one absolute deadline a\nresumed run re-attaches to.\n`turn` wakes one seat for one host turn through the manager as a pull-shaped relay: the run\nsubmits the turn under the step's own identity, the manager holds it as a goal pinned to the\nseat's incarnation, and the seat pulls it under its own reach ahead of its next host turn, so\nnothing is pushed into a session mid-thought. The payload the seat reads names the run and the\nstep and carries the rendered run context, plus any pending notices addressed to it, which the\nturn consumes. The seat yields through `cotal_yield` (`done`, `blocked`, or `handoff` with an\naddressee), and ending its host turn yields `done` for every turn it was shown. A `handoff` names\nanother seat the same run spawned: the next `turn` in the same scope to that seat records the\nlink, a handoff to a name the run never spawned is the catchable L4005, and one to a seat bound\nto a different worktree is L4004. The deadline elapsing before any yield is the catchable L4003:\nthe acceptance names the instant, the manager's goal-bound hold denies at it, and the run arms its\nown pause on that same instant, so either side outliving the other still converges on the same\nanswer. A seat that dies mid-turn is read off its own presence row by the run itself, with the\nsame 30-second confirmation for a lapsed row, and is the catchable L4002, and a death the manager\nmarked on the deadline terminal reads the same way. Two\nturns on one seat, from two branches or from two runs, reach it one at a time: the language\ndispatches the second when the first settles, and the manager shows a seat the oldest unsettled\nturn alone. A turn's goal id belongs to the caller that submitted it, so two callers may each submit\na turn under the same id. A seat yields by goal id alone, so the manager refuses to hand one seat\na second caller's turn under an id it already holds. On an auth mesh the relay needs no extra grant: every spawned seat's baseline\ncredential carries its own pull, yield, and caller-bound answer rows, the run driver's operator instrument carries the\nturn request and the `inspect` read that confirms a lapse, and the manager arms the deadline hold over its own serve grant and expires it\nitself once due. An accept the manager cannot finish is unwound to a failed terminal on the goal\nit bound, and a retry of that submission is refused naming the terminal rather than accepted a\nsecond time.\n`wait(replied(...))` observes those turns from another branch: a completed turn is a reply, and\nthe wait resolves with the observation record (the handle, the yield's status and note, the\nyield's own stamp). It reads as a level, the way `wait(down)` does: a reply that already exists\nresolves the wait at once, and two replies resolve to the latest by the yield's stamp. A denied\nor cancelled turn is never a reply, so an unanswered wait rides its own mediated timeout to\n`null`, and a handle the run never spawned or turned refuses loudly, since only this run's turns\nare observable. A turn the run itself ended without an accepted yield (its deadline, a\ncancellation, a refused handoff) is never a reply, whatever the seat yields to the relay later.\nA cancelled branch also withdraws what it relayed to a seat before its cancellation completes: its\nturn, its ask attempt or its escalation ends `cancelled` through the manager's reserved `cancel`\n(SPEC \xA713.6), so the seat is not shown it after the branch's scope settles and the next turn to\nthat seat does not wait behind it. Only the manager that accepted the relay holds it, so in a\nspace with more than one manager the run sends the cancel again while another manager refuses it,\nuntil the accepting one answers. A cancel the accepting manager refuses is sent again until it\nlands or the relay's deadline passes, so the branch's cancellation does not complete while the\nseat can still be shown the relay. The run reads that deadline from the relay's goal and retries\na failed read, since the relay may still be served until the deadline is known. A goal record\nthat can never yield a deadline, such as one that is not JSON, fails the branch's step at once,\nand so does a refusal that lasts past the deadline.\nA `spawn` may bind its agent to a **logical worktree** (`spawn(\"builder\", { worktree: \"wt-1\" })`):\nthe handle carries the id, and the run enforces the one rule the language states about it: two\nagents never share a worktree concurrently. The validator rejects the literal case up front\n(L3022: two branches of one concurrent scope spawning into one literal worktree, named branch\nfunctions included), and the runtime guards the rest, computed ids included: a spawn claims its\ntree before it submits, so a second spawn into a tree held by a live seat or by a spawn still\nbringing one up is the catchable L4008, a spawn that ends without a handle gives the tree back,\nand the tree is reusable the moment a holder's presence row is gone, so a discharged race loser\nor a crashed seat releases its tree with no bookkeeping. A spawn the endpoint refuses at accept\nis the catchable L4000 (L4001 when the refusal is the endpoint's seat capacity), and one whose\nseat never came up is L4002. A refusal that states the command did not run is answered\nbefore it gets that far. In a space served by more than one manager the resolve and the invoke\nare separate trips through the same anycast queue, so a run's call can reach an instance it did\nnot resolve against, and that instance refuses ahead of any effect. The run drops its resolved\nhandle, re-describes and re-issues, for a bounded number of attempts; after them the refusal\nsurfaces as the effect's own failure and still states that nothing ran. A spawn that names a\n`placement` addresses one instance by name, so a refusal from it is that incarnation answering\nabout itself and is never re-issued. When the spawn also names `cwd`, that manager resolves the\nexisting absolute directory to its canonical host path before accepting the spawn. A missing,\nrelative or non-directory path refuses with no seat and never falls back to the manager workspace.\nOne hosted run may name one placement instance; a program naming several is refused instead of\nwidening one run credential across hosts. Placement is accepted only from an object-literal spawn\noption bag whose `endpoint` and `instanceId` are string literals. A computed option bag, a computed\nplacement field, or a spread in either object refuses at `run-start`. The option argument may be\nabsent, and an object-literal bag without placement is accepted.\nA turn handoff across worktrees is the L4004 described above. Recovery keeps these honest: a resumed run\nreseeds its roster, holders and handoff memos from its own journal, and the driver re-issues any\nrecorded-but-undischarged cancellation at adoption, before the engine performs a new step, so a\nloser a crash left alive does not keep its seat or its tree while the resumed run works on. The\nsame sweep withdraws a cancelled branch's relays and undelivered notices: a notice waits on the run for its\naddressee's next turn, so a decision the run cancelled would otherwise arrive at an agent with\nnothing to distinguish it from one that stood.\n\nEvery effect the language defines performs on the mesh handler; nothing is refused as\nnot-yet-durable any more. The operator surface over the driver is `cotal run`; the section above has the verbs.\n\n**Two engines, and which one runs your program.** The tree-walker is language version `1` and the\ncompiled engine is version `2`, two languages rather than two speeds of one (`spec/cotal-lang.md`\n\xA78.4 lists what differs). The driver hosts both: **every run a driver starts is stamped `2` and\nexecuted by the compiled engine**. The program runs in its own locked-down worker thread with\nnothing in its global scope, while the effects and the durable journal stay in the driver's process,\nbridged over a message port. No socket or credential enters the isolate holding the program,\nand **every version-`1` record keeps replaying on the walker**, which is the walker's job. On either\nengine the driver bounds an effect's `ok` result at the broker's `max_payload` less 4096 bytes: a\nlarger result is refused ahead of the settling append (**L5006**), the step stays pending, and the\nrun is released. The driver serves a declared set of versions, and a record whose version it does\nnot serve is refused by name (**L5023**) with the run left untouched, instead of being replayed by\nwhichever engine happens to be present. Records do not cross between versions in either\ndirection; the repair is to resume on the recorded version, or to fork.\n\n**The engine needs node 22 or newer** and refuses below it as `EngineUnavailable`, which is an\nimplementation limit and not a language error: it carries no `L` code, so there is nothing to look\nup in the catalog. It is a floor rather than a warning because the engine's frame plumbing rests on\n`AsyncLocalStorage`, and 22 is the lowest node it has been measured on. The walker has no such floor.\n"
62744
62747
  }
62745
62748
  ],
62746
62749
  "spec": {
@@ -64642,7 +64645,7 @@ var cotal = async () => {
64642
64645
  if (guard.__cotalOpencodeHooks) return guard.__cotalOpencodeHooks;
64643
64646
  const config2 = configFromEnv();
64644
64647
  const control = controlFromEnv();
64645
- const eventsArmed = envFlag(process.env, "COTAL_EVENTS") || config2.eventsRequired;
64648
+ const eventsArmed = eventPlaneArmed(config2);
64646
64649
  scrubLaunchMaterial();
64647
64650
  config2.connector = "opencode";
64648
64651
  const serverUrl = process.env.COTAL_OPENCODE_SERVER_URL?.trim();