@cotal-ai/pi 0.60.0 → 0.61.0

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.
package/dist/index.js CHANGED
@@ -19473,7 +19473,7 @@ import { isConcreteChannel as isConcreteChannel3, channelInAllow as channelInAll
19473
19473
 
19474
19474
  // ../connector-core/dist/docs-bundle.generated.js
19475
19475
  var DOCS_BUNDLE = {
19476
- "version": "0.60.0",
19476
+ "version": "0.61.0",
19477
19477
  "generatedFrom": "docs/*.md + SPEC.md + spec/cotal-lang.md + spec/cotal.schema.json",
19478
19478
  "pages": [
19479
19479
  {
@@ -19558,7 +19558,7 @@ var DOCS_BUNDLE = {
19558
19558
  "title": "Connect Claude",
19559
19559
  "kind": "Guide (informative)",
19560
19560
  "summary": "The Claude Code connector turns a real claude session into a Cotal mesh peer.",
19561
- "body": "# Connect Claude\n\n> **Guide** (informative) \xB7 **For:** operators \xB7 **Prereqs:** [Quickstart](getting-started.md)\n\nThe Claude Code connector turns a real `claude` session into a Cotal mesh peer. A bundled\nplugin inside the session joins NATS, maps lifecycle hooks to presence, and exposes the\nmesh tools. Nothing wraps Claude; it is an ordinary session that happens to be on the\nmesh.\n\nThe shared mesh runtime (agent, `cotal_*` tools, hook relay) lives in\n[`@cotal-ai/connector-core`](../extensions/connector-core); this connector is the thin\nClaude-specific adapter over it. Siblings: [OpenCode](connect-opencode.md) (beta),\n[Hermes](connect-hermes.md) (alpha), [pi](connect-pi.md) (alpha); the\n[Connectors](connectors.md) matrix compares them feature-by-feature.\n\n## Set up\n\n```bash\ncotal setup # one-time: installs the plugin, seeds one agent; launches nothing\ncotal up # brings up the mesh + delivery daemon + a detached manager\n```\n\n`cotal setup` installs the cotal plugin (so the repo's Claude sessions get the `cotal_*`\ntools) and seeds one `default` persona; `cotal up` brings up the local stack so\n`cotal spawn --detach` / `cotal_spawn` work right away. Re-running either is idempotent.\nThe install mechanics and the invariants behind them are in\n[setup internals](setup-internals.md).\n\n`cotal setup` also installs Cotal's authored Agent Skills (`SKILL.md`, the agentskills.io format) for\ncoordinating agent teams (today `team-topology`), from one canonical source, on two channels:\n\n- **Claude Code** gets a second, skills-only plugin, `cotal-skills`, from the same `cotal-mesh`\n marketplace, at **user scope** (machine-wide). The Claude connector declares and implements this\n setup provider, including the marketplace assets and native plugin commands; the base CLI only passes\n the vendor-neutral Agent Skills directory. The plugin carries no code and no core dependency,\n and uninstalls on its own with `claude plugin uninstall cotal-skills --scope user`. Its plugin version\n is stamped from the running CLI release, so an upgrade + `cotal setup --skills` runs `claude plugin update` and\n the deployed install actually gets the new skill. `cotal setup` installs it on first run and on repeat\n runs, so upgraders are not left behind. The same provider reports the plugin and skills plugin rows\n in `cotal status`, which point a stale or missing skills plugin at `cotal setup --skills`.\n- **Every other harness** (Codex, Cursor, OpenCode, Gemini CLI, Windsurf/Devin) reads the cross-vendor\n `~/.agents/skills/` directory convention, which has no remote index, so `cotal setup` **reconciles** it\n (and `cotal setup --skills` does only that):\n it installs/updates each Cotal skill, backs up a copy you have edited to `SKILL.md.bak` before\n replacing it, and removes a Cotal skill that is no longer shipped. Only skills Cotal owns are touched;\n your own or third-party skills there are left alone. `cotal status` reports whether the drop is current,\n stale, missing, or has a retired skill to reconcile, and names `cotal setup --skills` as the remedy. This is the working cross-vendor path.\n\nCotal also generates an [Agent Skills discovery index](https://cotal.ai/.well-known/agent-skills/index.json)\non cotal.ai, but that RFC is still a draft with no harness consuming it yet, so it is a forward bet,\nnot a channel to rely on today.\n\n## Spawn a session\n\n```bash\ncotal spawn # foreground: your default agent, in this terminal\ncotal spawn dave --detach # supervised: the manager runs it in a PTY\n```\n\nA spawn resolves a persona from `.cotal/agents/<name>.md` ([agent files](agent-files.md));\n`--model`, `--variant`, `--cwd`, `--prompt`, ACL overrides, and `--share-tools` apply to\nboth forms ([run a mesh](run-a-mesh.md) has the full resolution rules). The session joins\nwith identity from its environment and auto-registers presence by the time it is\ninteractive.\n\nInside the session, the agent orients with one read-only tool, `cotal_orientation`: its\nidentity, the channels it reads and may post to, its capabilities, the tools available,\nwho's present, and unread counts. The full tool surface is the\n[MCP tool catalog](mcp-tools.md). In auth mode the team-supervision tools\n(`cotal_spawn` / `cotal_persona` / `cotal_personas`) are injected **only** for personas declaring\n`capabilities: [spawn]` (the same grant that opens the privileged control subject), so an\nagent's toolset matches its declared capabilities. `cotal_run` is gated separately by\n`run`; use `capabilities: [spawn, run]` for both. Fresh setup defaults include both.\nSee [workflow tool setup](workflows.md#from-an-agent-session) for a first run and missing-tool checks.\nClearing retained history is\noperator-only ([run a mesh](run-a-mesh.md)), never an agent tool.\n\n## How it binds\n\nClaude Code exposes four integration surfaces, and three of them collapse into a single\ndual-purpose MCP server:\n\n| Surface | Mechanism |\n|---|---|\n| Outbound, ambient | `http` lifecycle hooks \u2192 POST to the connector (presence, activity) |\n| Outbound, deliberate | MCP tools `cotal_send` / `cotal_dm` / `cotal_anycast` (+ `cotal_feedback`) |\n| Inbound, pull | MCP tool `cotal_inbox` (same server) |\n| Inbound, push | Channel nudge + hook drain (below) |\n\nThe manager launches the *real* `claude` (no wrapper):\n\n```\nclaude --strict-mcp-config --mcp-config '{\"mcpServers\":{\"cotal\":{\u2026}}}' \\\n --dangerously-load-development-channels server:cotal\n# env: COTAL_SPACE, COTAL_NAME, COTAL_ROLE, COTAL_CHANNEL=1, plus claude's documented auth vars\n```\n\n- **Model auth.** Locally, `claude` still reads macOS Keychain / `~/.claude`. In a container or\n CI there is no Keychain, so the connector forwards the documented credential set:\n `CLAUDE_CODE_OAUTH_TOKEN` (from `claude setup-token`), `ANTHROPIC_API_KEY` /\n `ANTHROPIC_AUTH_TOKEN`, and the cloud-provider flags plus their credential vars. Host-session\n markers (`CLAUDE_CODE_CHILD_SESSION`, `CLAUDECODE`) stay out so a nested seat still saves a\n transcript. See [Deploy](deploy.md).\n- **Persona privacy.** The persona body is written to a private file and Claude receives only\n `--append-system-prompt-file <path>`. The body never appears in the spawned process argv. The\n carrier is a 0600 file inside a 0700 directory on POSIX, with equivalent owner-only ACL hardening\n on Windows. That is OS-user isolation: any process running as your user can read it while it\n exists. The manager or the foreground `cotal spawn` removes it, and the shared-server MCP config\n file, once it has proved the `claude` process gone. If the launcher is killed first, a watcher\n started beside `claude` removes them when `claude` exits.\n- **MCP isolation.** A spawned agent runs with **only** the cotal MCP server:\n `--strict-mcp-config` ignores every other MCP source, crucially the operator's personal\n `~/.claude.json` servers (several spawns each booting a heavy helper would starve\n memory). Share your own servers deliberately (see below).\n- **Installed plugin.** The plugin is installed once (`claude plugin install\n cotal@cotal-mesh --scope local`) because its hooks bind only to an *installed* plugin.\n In a clone the marketplace is the repo's `.claude-plugin/marketplace.json`; `cotal setup`\n (npx, no clone) materializes the same marketplace under `~/.cotal/claude-plugin/` (each plugin dir is\n rebuilt from scratch and atomically replaced, never merged, so no stale file rides in). The\n `cotal-skills` plugin installs from that same marketplace at user scope (`claude plugin install\n cotal-skills@cotal-mesh --scope user`); its manifest and install behavior ship inside the Claude connector, and\n its version tracks the CLI release so updates land.\n- **Identity-gated.** Connector code requires `COTAL_NAME`, `COTAL_LINK` or `COTAL_AGENT_FILE`.\n A plain `claude` with none of them never joins, so your own sessions in a repo do not appear\n as stray peers. Its MCP server still answers `initialize` and lists one static tool,\n `cotal_how_to_join`, which explains how to launch a session on a mesh. It builds no mesh\n agent, opens no broker connection and binds no control socket.\n- **Hands-free.** The dev-channels flag prints a one-time confirm prompt. The PTY runtime waits for\n the dialog title in normalized terminal output and presses Enter once when it appears, so startup\n speed does not affect a supervised launch. If the declared prompt never appears, the seat exits\n with a bounded error naming the unmatched prompt instead of hanging silently.\n\nInbound mesh messages arrive in context as\n`<channel source=\"cotal\" from=\"bob\" kind=\"dm\" \u2026>\u2026</channel>`: each meta key a tag\nattribute the agent can read for routing.\n\n## How messages reach the session\n\nDurable deliveries land in the connector's inbox from JetStream consumers\n([SPEC \xA78](../SPEC.md#8-nats--jetstream-binding)); live channel traffic can instead arrive\nthrough an at-most-once core subscription. A durable message sent while the agent is busy\nor offline waits on the stream. Two things move a message from inbox to model; one\ndelivers, the other only wakes:\n\n- **Hook drain (delivery).** `SessionStart` / `UserPromptSubmit` hooks read automatic inbox items and\n inject them as `additionalContext`. This is the single authoritative path: deterministic and works\n on any Claude Code build. Quiet ambient is excluded and stays buffered for `cotal_inbox`.\n A message is **acked only once the hook reply carrying it has cleared both legs of its journey**:\n the connector's control socket to the hook process (which gives up after 2s), and the hook\n process's own stdout to Claude Code (which it force-exits 1s after starting to write). The relay\n sends a receipt back down the control socket from that stdout write's callback, and only on a\n clean write (a runtime whose pipe has gone away fails it), and the connector treats that receipt,\n not its own socket write, as delivery. So a large injection killed mid-flush, or one written to a\n broken pipe, leaves the message un-acked and JetStream redelivers it. What this does *not* prove is\n that Claude Code read or applied the reply: a payload small enough to fit the pipe buffer is\n reported written the moment the kernel takes it. That residual is why the path errs toward\n at-least-once rather than treating a confirmed write as a confirmed read. Acking when\n the reply was merely *formatted* meant a lost reply was a lost message: it was already marked\n handled, so its own redelivery was silently acked on arrival.\n A hook whose handler throws still returns an empty reply so the session is never blocked, and\n that reply carries nothing, so it commits nothing: the batch it had started to surface stays\n un-acked and goes out on a later frame. The seat also drops any `turn-pending` row that breaks\n the manager contract, such as one with no integer deadline, and says so once in its log. A reply\n with no `turns` array changes nothing: the seat keeps the turns it already holds.\n This errs toward **at-least-once**: if a reply lands but its confirmation does not, the batch is\n surfaced again and flagged as a possible repeat. A duplicate injection is noise; a buried DM stops\n the peer answering at all.\n- **Channel nudge (wake).** An arriving message fires a `notifications/claude/channel`\n event that wakes an *idle* session into a turn, so the drain runs *now* instead of at\n the next prompt. The nudge never acks anything. A nudge that the host rejects is retried with a\n bounded backoff while anything is still pending. For an idle session it is the only wake source,\n so dropping it means silence until someone types. When the channel becomes active, the connector\n first re-fires a focus mention remembered during startup, otherwise one buffered wake. A rejected\n push keeps its bounded retry, and JetStream redelivery remains the durable backstop for unacked\n inbox items. If the channel cannot run at all, delivery still waits for the next hook. Live-only\n traffic has no durable retry.\n\n**Two priority tiers.** A *directed* message (DM, anycast, or a channel message that\n`@mentions` us) always nudges. *Ambient* channel chatter does not nudge mid-turn; it\naccumulates, and the `Stop` \u2192 idle transition fires one batch nudge so the backlog drains\ntogether.\n\n**Constraints (accepted).** Channels are a Claude Code research preview (\u2265 v2.1.80;\npermission relay \u2265 v2.1.81): Anthropic auth only, admin-enabled on Team/Enterprise, and a\ncustom channel needs the `--dangerously-load-development-channels` launch flag. The hook\ndrain does not depend on any of that; the channel only adds \"wake me when idle.\"\n\nThe same channel also relays **tool-permission requests** onto the mesh, so a peer (a\nhuman at the CLI, a policy node) can approve or deny an agent's pending tool call through\nCotal rather than a per-terminal prompt.\n\n### Attention\n\nAn agent picks how aggressively peer traffic reaches it with\n`cotal_status({ attention })` (three modes, orthogonal to presence):\n\n| arrival | open (default) | dnd | focus |\n|---|---|---|---|\n| directed (dm / anycast) | wake + inject | wake + inject | wake + inject |\n| channel `@mention` | wake + inject | wake + inject | ack-drop; wake to *pull*; not injected |\n| ambient channel chatter | wake when idle; hold while working | never wakes; injects next turn | ack-drop; recall via `cotal_inbox` |\n\nPer-channel overrides refine this: **quiet** (delivered, never wakes; `@mention` still\nwakes) and **muted** (dropped on receive, mentions included; DMs/anycast unaffected), set\nwith `cotal_channel_mode` or as agent-file defaults (`quiet:` / `muted:`,\n[agent files](agent-files.md)). A per-channel override is the final word for that channel.\nQuiet ambient is pull-only: it never hitchhikes on a human prompt, DM, mention, or other\nconnector-driven turn. `cotal_inbox` explicitly surfaces and clears it. A quiet-channel\n`@mention` remains automatic and injects normally.\n\nA pull is bounded too, and clears only what it hands over. One `cotal_inbox` call carries at most a\nreceivable window (direct messages and role requests first, then channel traffic, replayed history\nlast); whatever does not fit stays buffered, is named in the reply, and comes back on the next call.\nA message too large for one whole response is delivered in parts: once no smaller mail is waiting,\neach call carries its next part, and it is cleared only after its last part goes out, because clearing\nwhat was not handed over is the loss this bound exists to stop.\nThat matters most on the path where it is easiest to lose mail: reconnecting brings a channel-history\nreplay with it, so the largest payload and the least expendable message arrive in the same read.\n\nThe local inbox is bounded. On pathological overflow it evicts pull-only items before automatic\ntraffic. If the bounded live/durable classification guard also fills, the connector fails closed:\notherwise-normal ambient becomes pull-only until restart. Muted hard-drop and normal focus recall\nstill take precedence. Focus also keeps a bounded exclusion list so mode toggles cannot recall\nquiet/muted traffic; if that safety bound fills, recall skips the affected channel and reports it\nas incomplete rather than risk resurfacing excluded content. Recall cannot tell one message with an\nempty id from an identical one with another disposition, so in focus such a message is held in the\nlocal inbox as pull-only instead of being dropped, and a mention of it still wakes the agent. When\nthe session settles an id-less copy, it reads the chat stream's last sequence. Identical copies\narrive in stream order, and those reads can answer out of order, so the first read that can see the\ncopies binds them in arrival order, latest first, each to the latest unbound stream copy at or below\nthe lowest sequence read for it or any later identical copy.\nWhile the connection stays up, every stream copy at or below that sequence reached the session\nfirst, so a later identical copy sent during a reconnect gap is above it and stays unbound. A copy\nthat arrives while a read runs may not be in that read, so it binds nothing there and recall reads\nthe channel again, up to three times; if it still could be a stream copy in the last read, recall\nleaves that stream copy in the stream and reports the channel as incomplete. A settled copy a\ncomplete read cannot bind is behind the focus start or out of retention, and is forgotten. Recall\nhands back into the inbox only the stream copies nothing is bound to, such as one sent during a\nreconnect gap or one the inbox evicted on overflow, and `cotal_inbox` hands each over once. Overflow\nfrees only the evicted copy, held or quiet, and a copy no read has bound yet keeps its place in\narrival order, so an identical muted copy stays out of recall. When\nthe inbox is full, recall leaves them in the stream for a later call and reports the channel as\nincomplete. A history read that fails, or a channel with replay off, settles nothing and is reported\nas incomplete, and recall calls run one at a time. If the sequence read for a settled copy fails,\nor answers only after the connection dropped, recall skips that channel for the rest of the focus\nperiod and reports it as incomplete. Recall cannot tell a late copy of a message it handed back from\na new identical message, so every copy takes its own disposition: a new identical quiet mention is\nstill delivered automatically, and a late copy can surface a second time.\nA recalled message that already went out in part is read to its last part, even if an exclusion\nlands after its first part. One session reads its inbox one call at a time: a `cotal_inbox` call\nthat overlaps another waits for it to finish, so neither decides from a view the other has already\nmoved past.\nIf the separate hard-drop disposition guard fills, channel traffic is dropped for the rest of the\nsession rather than risk a late copy bypassing an earlier muted/focus decision; DMs and anycast are\nunaffected.\n\nAttention is **advisory UX, not a boundary**: any peer can wake a dnd/focus agent by\nnaming it, and `muted` means \"I opted out of receiving\", not \"the channel is blocked\";\nthe broker still authorizes and delivers. Focus's real effect is shrinking the\nuntrusted-ambient injection surface (only subject-authenticated dm/anycast auto-inject).\nIt resets to **open** on `SessionStart`, so a restarted agent never stays silently deaf.\nYour attention is mirrored into presence so peers can see it.\n\nWhatever does reach a turn is framed so a peer cannot write the frame. A line that begins at column\nzero is written by the connector; one message is one line plus indented continuations, with the\nsender inside a single bracket pair. A message body, a sender name and role, and a service or\nchannel label are all peer-controlled, so each passes through the same neutralization the\n`cotal_inbox` reply uses: no line break a splitter may honour and no bracket survives into a\nrendered attribution. This matters more for an injected block than for a reply, because the agent\ndid not ask for it and so never had the chance to distrust it.\n\n## Presence mapping\n\nThe connector wires a small subset of Claude Code hooks to presence states; presence is\ncoarse, and \"what it is doing\" rides on activity updates. Presence is **advisory**: a presence\npublish that fails (the endpoint mid-reconnect, say) is swallowed and never prevents the same hook\nfrom delivering messages or flushing held ones.\nA `SessionStart` during an open turn, including compaction, preserves the current `working` or\n`waiting` status until `Stop`, `StopFailure`, or `SessionEnd` closes the turn.\n\n| Hook | \u2192 state |\n|---|---|\n| `SessionStart` | `idle` only when no turn is open (join; surfaces the inbox; captures the live model into `meta.model` when no pin) |\n| `UserPromptSubmit` | `working` (turn starts; surfaces the inbox) |\n| `PreToolUse` | no change; records *what* is about to run, so a permission wait can name it |\n| `Notification` (`permission_prompt` / `agent_needs_input`) | `waiting` with condition `approval` / `input` (activity leads with the pending tool, e.g. `Bash: git push \u2026`) |\n| `Stop` / `StopFailure` | `idle` (turn done / died on an API error; flushes anything held while busy). `StopFailure` also relays Claude Code's native error value as `condition.source` and maps it to the closed condition vocabulary. On the [event plane](#event-plane) it closes the run with `RUN_ERROR`. |\n| `SessionEnd` | `offline` (graceful leave) |\n\n`StopFailure` maps `rate_limit` and `overloaded` directly; auth and credential failures to\n`auth`; account and billing failures to `billing`; `invalid_request` to `request`;\n`model_not_found` to `model`; `server_error` to `server`; `max_output_tokens` to `context`; and\n`unknown` to `failed`. The native value remains in `condition.source`.\n\nHooks are relayed over the connector's **authenticated** local control endpoint (per-user\nsocket + per-launch token, constant-time checked), so a local process that finds the path\nstill can't drive presence or stop the agent. The full Claude Code hook-event list lives\nwith the adapter:\n[`extensions/connector-claude-code`](../extensions/connector-claude-code/README.md).\n\n## Event plane\n\nA spawned session publishes a **structured** account of what it\ndid: run boundaries per turn, assistant text, reasoning, and each tool call with its start\nand its end. Not prose about the work, the work itself, in a vocabulary a program can\nread. The launcher sets `COTAL_EVENTS` by default; pass `--no-events` to opt out on an unrestricted\nspace. A user-auth registration with `policy: { events: \"required\" }` carries `eventsRequired` in the\nprivate launch material, so the connector arms even without `COTAL_EVENTS`; `--no-events` is refused.\nA hand-driven user-mode session may carry the same decision as `COTAL_EVENTS_REQUIRED=1`. Its own\npublish grant must cover `events.<owner>.<actor>` or the connector refuses before joining. An unmanaged\nsession with no launch material and no required-policy fallback keeps the generic default behavior.\n\nA new session includes its first run even when Claude writes a positional startup prompt before the\nconnector receives `SessionStart`. That from-zero read is keyed only to Claude's explicit\n`source: \"startup\"`; resumed, forked, cleared, and compacted sessions adopt at the transcript boundary\ncaptured at that adopt, before the mesh link connects, so nothing Claude appends while the connector\nis still starting up lands behind the cursor and is silently dropped. Crash recovery follows the\ncursor already stored in the event write-ahead log, regardless of the new process's startup label.\n\nClaude starts each hook in its own process, so a prompt or stop relay can reach Cotal before the\n`SessionStart` relay. The connector holds those event flushes and the terminal until `SessionStart`\nsupplies the source, then enqueues adopt, flush, and close in that order.\n\n`SessionStart` can also run before the connector process has bound its local control socket. The hook\nthe `SessionStart` relay retries only transient pre-connect listener errors, with capped backoff\ninside its existing two-second budget. Later hooks and permanent local faults still fail open\nimmediately. Once a socket has connected, a broken exchange is not retried: the connector may\nalready have handled the frame, so replaying it could apply one lifecycle event twice.\nThat retained `SessionStart` can itself arrive before Claude creates the transcript path. A genuinely\nnew startup waits up to five seconds for that file with capped backoff, and the same deadline bounds\none stalled file read; expiry fails loud instead of silently losing the first run. A forked session\ngets the same wait, because Claude copies the parent transcript into the fork's own file after the\nhook, and then adopts at the end of that copy. Resumed, cleared and compacted starts and recovered\ncursors still require their existing source at once.\n\nTool arguments (`TOOL_CALL_ARGS`) and tool results (`TOOL_CALL_RESULT`) are not republished\nonto this channel. The durable emitter drops those events before they are written to the\nwrite-ahead log, because this channel's read ACL is not the ACL the tool ran under. Content is\nmandatory on both kinds, so the event is suppressed rather than emptied or replaced with a\nplaceholder. Tool start and end still go out. A restart that finds a pending pre-fix frame\nstill carrying those kinds HALTS rather than republishing it.\n\nThe channel is **`events.<owner>.<actor>`**, named after the session's principal. What the actor\nhalf is depends on the mesh, and the difference matters when you go looking for it: on a static mesh\nit is a key the manager allocated, never the display name, so two live agents sharing a display name\ndo not share a stream; on a user-auth mesh it is the agent's own name, because that is what the\nledger row is keyed on. Spelled out again with both halves below. The launch grants publish rights\non that channel alone. A spawn\nthat asks for a *different* agent's event channel is refused at the door rather than granted, since\nthat channel is that session's event stream. The same rule runs on restart: a manager\nresume document that names another agent's event channel is refused rather than adopted, because the\nmanaged row is re-armed from that document and the credential is re-minted from the row.\n\nThe rule reads a **concrete** channel, two principal tokens and nothing else. A pattern such as\n`events.<owner>.>` is not an event channel to it and passes untouched, governed by ordinary ACL\nauthority: on a user mesh the delegation envelope, on a static mesh the spawning credential itself.\nThat is deliberate, because the pattern is the form an operator writes on purpose for an observer,\nand it is worth knowing rather than assuming the fence is total.\n\nTo let something else read a plane, grant it out of band. The refusal prints the command for the\nmesh it is running on, spelled out in full, and only that one.\n\nOn a **user-auth** mesh:\n\n```bash\ncotal actor grant <reader> --owner <owner> --scope '' --allow-subscribe 'events.<owner>.<actor>' --allow-publish ''\n```\n\nEvery field, deliberately. `actor grant` is an upsert of the whole row, so it refuses a grant that\nleaves off any of the three ACL flags. Only `--full` turns an omitted flag into the wide default\n(`>` read, `>` post, `spawn,role:default` scope), which is the opposite of what a scoped watcher is for.\n\nOn a **static** mesh there is no actor ledger for `actor grant` to write to, and the refusal says\nso; mint the reader instead:\n\n```bash\ncotal mint watcher --profile agent --allow-subscribe 'events.<owner>.<actor>' --provision\n```\n\nThe **agent** profile, not the observer one. `mint` reads `--allow-subscribe` only for that\nprofile, and refuses it anywhere else: `--profile observer --allow-subscribe <channel>` exits\nnon-zero and writes no creds file, because the observer profile carries a fixed read set over the\nwhole chat plane, which is the opposite of what a scoped watcher is for. The agent profile also prints the lifecycle uid the\nreader needs, since an authed consuming endpoint refuses to start without one.\n\nOn an **open** mesh there is nothing to grant: the mesh has no credentials and no ACLs, so any peer\nthat lists the channel reads it, and the refusal says so instead of naming a command. The\nown-channel rule still applies there, because a spawn is not the place to hand out a read on\nanother agent's tool inputs and outputs.\n\nTwo things a reader has to do that are not obvious, both on `CotalEndpoint`. It must pass the event\nchannel in `channels`: an endpoint reads the channels it lists, so one constructed without\nthe event channel joins nothing and the frames never arrive. And it reads history with `readHistory(channel)`, the delivery daemon's mediated read, not\n`channelHistory(channel)`: a scoped credential is denied the ad-hoc consumer the direct read\ncreates, by design. `cotal console` and the web console already do both.\n\nThe `<owner>.<actor>` pair is the session's principal. On a user-auth mesh the actor half is the\nagent's own name, so the channel is `events.<your-owner>.<agent-name>`. On a\nstatic mesh the owner half is the literal `local` and the actor is a key the manager allocated, so\nthe channel is `events.local.<key>`; the spawn reply carries that key as `id`. Note\nthat `cotal console` and the web console keep event channels out of their channel lists on purpose,\nsince a plane is a machine feed rather than a conversation; they draw the frames when you open the\nchannel by name.\n\nThe rule governs the manager's doors, which are the ones a caller other than you can reach. A\nforeground `cotal spawn` on your own machine mints from your own signing material, so it can still\ngrant any channel you name: that is the out-of-band grant, not a way around the rule.\n\n**Failed turns publish run errors.** Claude Code decides for itself\nwhether a turn finished or died and fires one of two hooks accordingly, so the connector relays that\ndecision rather than making one of its own: a turn that ended on an API error ends its run with\n`RUN_ERROR` carrying the fixed message `run failed` and no code. Neither the detail Claude Code\nreported nor its error kind is published there: both are upstream values that can echo your prompt or\ntool output, and the events channel has a different read ACL. The error kind still reaches presence\nas the agent's condition (`rate_limit`, `auth`, `billing` and the rest). A turn that ended normally still\nends with a run-finished event carrying no outcome, which says the turn ended and does not claim it\nsucceeded.\n\nEvents are written to a per-session write-ahead log before they are published, so a hook that fires\nafter a restart resumes at the cursor it left rather than replaying or skipping, and a run that was\nopen when the session stopped is closed rather than left dangling.\n\nOne channel carries **every session of one agent**, because it is named after the principal and not\nafter the session. Alongside the per-session logs the connector keeps one small record per principal,\nholding the last sequence the broker assigned on that channel, so a new session continues the stream\nits predecessor left instead of starting again from nothing. Both live under the events state root\n(`COTAL_WORKSPACE_ROOT`), and neither is something you edit by hand.\n\nA **missing** record is not a fault: the connector rebuilds it from the session logs beside it,\nwhich is how an agent that was already running before this record existed keeps its stream. That\nrebuild stops if any one of those session logs is damaged. Unreadable, not valid JSON, and written\nfor a different principal all count, and so does a session directory or a log that is a link rather\nthan the real file the connector wrote, or a log that has more than one name. A tip taken from the\nrest would be too low, and it would stop publication later with nothing left to point at the cause.\nThe connector names the file instead, and the only way past it is the directory removal described\nbelow, under the same condition. A record that **disagrees with the broker** is a fault, and the\nconnector stops publishing and says why rather than guessing. A record that **moved while a session\nwas writing to it** is refused the same way: it means something else wrote the principal's record,\nand the connector reports which value it held and which the file holds rather than writing over the\nlater one. There is no command to clear it. The state is the principal's directory under the events\nroot, and clearing it by hand means removing that directory whole: the sequence, the cursor and the\nper-session logs only mean anything together, so removing part of it leaves a state the next start\nrefuses. Removing it is only half a remedy, and the half that comes first is the channel. The\ndirectory is where the agent's memory of the tip lives, not the tip itself, so on a channel that\nstill holds frames the next session opens expecting an empty one and stops on the same\ndisagreement, with the logs a tip could have been rebuilt from now gone. Purge the channel first,\nthen remove the directory.\n\nReading it: `cotal console` and the web console draw event frames directly. A frame carries no text\npart by design, so a surface that renders a message as flat text shows a marker instead of prose.\n\n**On a per-user-auth mesh, the default event plane needs the spawner's grant to cover the channel.** The event\nchannel is added to the child's publish set, and delegation only narrows: an agent may hand down\na subset of what it holds and no more. So a peer-initiated spawn is refused unless the\nspawning identity's own grant already covers the child's event channel. The refusal prints the\nexact `cotal actor grant` command that widens it. An operator launch, whose chain reaches an\nadmin-scoped or roster row, is unaffected. Passing `events: false` is the explicit opt-out.\n\nArming the event plane through a typed spawn request (`manager.spawn` with `events`, including\nthe CLI's `cotal spawn --detach --events`) additionally requires the caller's admin tier on a\nuser mesh. A non-admin caller that asks for the plane is refused before anything is provisioned,\nand one that stays silent gets a spawn without it, with the reply saying so.\n\n## Resume a session\n\n`--resume <session-id>` pulls an existing Claude session, its context and transcript,\ninto the mesh. It **forks**: Claude mints a *new* session id from that transcript\n(`--resume <id> --fork-session`), so the meshed agent gets its own session and the\noriginal is untouched.\n\n- `cotal spawn --resume <id>` (foreground) is the primary surface: the transcript is on\n *your* machine, and errors are Claude's own stderr, inline.\n- `--detach --resume <id>` works, with two differences: the id resolves against the\n **manager host's** `~/.claude` (you practically need `--cwd`), and the manager waits for\n a real outcome; `\u2713 started` means the agent *joined the mesh*, `\u2717 exited on launch`\n carries Claude's last output, and an uncertain launch (~30 s) is reported without\n tearing the agent down.\n- Resume is an **operator surface only**, deliberately not exposed on MCP `cotal_spawn`\n (a mesh peer naming host-local transcripts would widen `spawn` into transcript\n disclosure). Only the Claude connector supports it today; OpenCode and Hermes fail loud.\n- Needs a `claude` new enough for `--resume \u2026 --fork-session` (verified on 2.1.197).\n\n## Sharing your MCP servers\n\nIsolation is the default, but a meshed teammate sometimes genuinely needs one of your own\ntools (say, web search). The opt-in is the cotal config file\n(`~/.config/cotal/config.json`, or a space-local `.cotal/config.json` layered on top):\neach entry the familiar `.mcp.json` shape, secrets written as `${VAR}` references, never\nliterals ([full format](config.md)).\n\nAt launch the connector forwards *only* the named vars the chosen servers declare and\npasses the merged config as an owner-only temp file; `--strict-mcp-config` stays on, so\nonly cotal + the explicitly shared servers load. Scope per spawn with\n`--share-tools tavily,figma` (or `--share-tools none`).\n\nTwo caveats: sharing a server grants its credential to the agent (the var lives in the\nClaude process's environment, so share only when you're fine with that teammate holding\nthe key), and memory adds up, because a heavy server boots once per spawn, multiplied\nacross a team.\n\n## Feedback\n\n`cotal_feedback` works out of the box: without a key it posts to the public intake at\n`https://cotal.ai/v1/feedback` (needs a contact email: `COTAL_FEEDBACK_EMAIL`, then\n`git config user.email`, else the agent asks). Set `COTAL_FEEDBACK_KEY=fbk_<key>` in a\nbeta tester's environment to route to the keyed intake (`Authorization: Bearer`, identity\nderived from the key); `COTAL_FEEDBACK_URL` overrides either endpoint. The CLI can send\ntoo: `cotal feedback \"<summary>\" [--type bug]`. Each submission carries\n`origin: human | agent`, whether the tester asked, or the agent auto-reported a major\nissue.\n"
19561
+ "body": "# Connect Claude\n\n> **Guide** (informative) \xB7 **For:** operators \xB7 **Prereqs:** [Quickstart](getting-started.md)\n\nThe Claude Code connector turns a real `claude` session into a Cotal mesh peer. A bundled\nplugin inside the session joins NATS, maps lifecycle hooks to presence, and exposes the\nmesh tools. Nothing wraps Claude; it is an ordinary session that happens to be on the\nmesh.\n\nThe shared mesh runtime (agent, `cotal_*` tools, hook relay) lives in\n[`@cotal-ai/connector-core`](../extensions/connector-core); this connector is the thin\nClaude-specific adapter over it. Siblings: [OpenCode](connect-opencode.md) (beta),\n[Hermes](connect-hermes.md) (alpha), [pi](connect-pi.md) (alpha); the\n[Connectors](connectors.md) matrix compares them feature-by-feature.\n\n## Set up\n\n```bash\ncotal setup # one-time: installs the plugin, seeds one agent; launches nothing\ncotal up # brings up the mesh + delivery daemon + a detached manager\n```\n\n`cotal setup` installs the cotal plugin (so the repo's Claude sessions get the `cotal_*`\ntools) and seeds one `default` persona; `cotal up` brings up the local stack so\n`cotal spawn --detach` / `cotal_spawn` work right away. Re-running either is idempotent.\nThe install mechanics and the invariants behind them are in\n[setup internals](setup-internals.md).\n\n`cotal setup` also installs Cotal's authored Agent Skills (`SKILL.md`, the agentskills.io format) for\ncoordinating agent teams (today `team-topology`), from one canonical source, on two channels:\n\n- **Claude Code** gets a second, skills-only plugin, `cotal-skills`, from the same `cotal-mesh`\n marketplace, at **user scope** (machine-wide). The Claude connector declares and implements this\n setup provider, including the marketplace assets and native plugin commands; the base CLI only passes\n the vendor-neutral Agent Skills directory. The plugin carries no code and no core dependency,\n and uninstalls on its own with `claude plugin uninstall cotal-skills --scope user`. Its plugin version\n is stamped from the running CLI release, so an upgrade + `cotal setup --skills` runs `claude plugin update` and\n the deployed install actually gets the new skill. `cotal setup` installs it on first run and on repeat\n runs, so upgraders are not left behind. The same provider reports the plugin and skills plugin rows\n in `cotal status`, which point a stale or missing skills plugin at `cotal setup --skills`.\n- **Every other harness** (Codex, Cursor, OpenCode, Gemini CLI, Windsurf/Devin) reads the cross-vendor\n `~/.agents/skills/` directory convention, which has no remote index, so `cotal setup` **reconciles** it\n (and `cotal setup --skills` does only that):\n it installs/updates each Cotal skill, backs up a copy you have edited to `SKILL.md.bak` before\n replacing it, and removes a Cotal skill that is no longer shipped. Only skills Cotal owns are touched;\n your own or third-party skills there are left alone. `cotal status` reports whether the drop is current,\n stale, missing, or has a retired skill to reconcile, and names `cotal setup --skills` as the remedy. This is the working cross-vendor path.\n\nCotal also generates an [Agent Skills discovery index](https://cotal.ai/.well-known/agent-skills/index.json)\non cotal.ai, but that RFC is still a draft with no harness consuming it yet, so it is a forward bet,\nnot a channel to rely on today.\n\n## Spawn a session\n\n```bash\ncotal spawn # foreground: your default agent, in this terminal\ncotal spawn dave --detach # supervised: the manager runs it in a PTY\n```\n\nA spawn resolves a persona from `.cotal/agents/<name>.md` ([agent files](agent-files.md));\n`--model`, `--variant`, `--cwd`, `--prompt`, ACL overrides, and `--share-tools` apply to\nboth forms ([run a mesh](run-a-mesh.md) has the full resolution rules). The session joins\nwith identity from its environment and auto-registers presence by the time it is\ninteractive.\n\nInside the session, the agent orients with one read-only tool, `cotal_orientation`: its\nidentity, the channels it reads and may post to, its capabilities, the tools available,\nwho's present, and unread counts. The full tool surface is the\n[MCP tool catalog](mcp-tools.md). In auth mode the team-supervision tools\n(`cotal_spawn` / `cotal_persona` / `cotal_personas`) are injected **only** for personas declaring\n`capabilities: [spawn]` (the same grant that opens the privileged control subject), so an\nagent's toolset matches its declared capabilities. `cotal_run` is gated separately by\n`run`; use `capabilities: [spawn, run]` for both. Fresh setup defaults include both.\nSee [workflow tool setup](workflows.md#from-an-agent-session) for a first run and missing-tool checks.\nClearing retained history is\noperator-only ([run a mesh](run-a-mesh.md)), never an agent tool.\n\n## How it binds\n\nClaude Code exposes four integration surfaces, and three of them collapse into a single\ndual-purpose MCP server:\n\n| Surface | Mechanism |\n|---|---|\n| Outbound, ambient | `http` lifecycle hooks \u2192 POST to the connector (presence, activity) |\n| Outbound, deliberate | MCP tools `cotal_send` / `cotal_dm` / `cotal_anycast` (+ `cotal_feedback`) |\n| Inbound, pull | MCP tool `cotal_inbox` (same server) |\n| Inbound, push | Channel nudge + hook drain (below) |\n\nThe manager launches the *real* `claude` (no wrapper):\n\n```\nclaude --strict-mcp-config --mcp-config '{\"mcpServers\":{\"cotal\":{\u2026}}}' \\\n --dangerously-load-development-channels server:cotal\n# env: COTAL_SPACE, COTAL_NAME, COTAL_ROLE, COTAL_CHANNEL=1, plus claude's documented auth vars\n```\n\n- **Model auth.** Locally, `claude` still reads macOS Keychain / `~/.claude`. In a container or\n CI there is no Keychain, so the connector forwards the documented credential set:\n `CLAUDE_CODE_OAUTH_TOKEN` (from `claude setup-token`), `ANTHROPIC_API_KEY` /\n `ANTHROPIC_AUTH_TOKEN`, and the cloud-provider flags plus their credential vars. Host-session\n markers (`CLAUDE_CODE_CHILD_SESSION`, `CLAUDECODE`) stay out so a nested seat still saves a\n transcript. See [Deploy](deploy.md).\n- **Persona privacy.** The persona body is written to a private file and Claude receives only\n `--append-system-prompt-file <path>`. The body never appears in the spawned process argv. The\n carrier is a 0600 file inside a 0700 directory on POSIX, with equivalent owner-only ACL hardening\n on Windows. That is OS-user isolation: any process running as your user can read it while it\n exists. The manager or the foreground `cotal spawn` removes it, and the shared-server MCP config\n file, once it has proved the `claude` process gone. If the launcher is killed first, a watcher\n started beside `claude` removes them when `claude` exits.\n- **MCP isolation.** A spawned agent runs with **only** the cotal MCP server:\n `--strict-mcp-config` ignores every other MCP source, crucially the operator's personal\n `~/.claude.json` servers (several spawns each booting a heavy helper would starve\n memory). Share your own servers deliberately (see below).\n- **Installed plugin.** The plugin is installed once (`claude plugin install\n cotal@cotal-mesh --scope local`) because its hooks bind only to an *installed* plugin.\n The repo's `.claude-plugin/marketplace.json` lists the committed plugin tree under\n `claude-plugin/`, which each release regenerates with the built bundles, the skills and the\n release version, so an install from the repo or from a pinned commit runs without a build\n ([Release](release.md)). `cotal setup` (npx, no clone) materializes the same marketplace under\n `~/.cotal/claude-plugin/` from the installed CLI (each plugin dir is rebuilt from scratch and\n atomically replaced, never merged, so no stale file rides in). The\n `cotal-skills` plugin installs from that same marketplace at user scope (`claude plugin install\n cotal-skills@cotal-mesh --scope user`); its manifest and install behavior ship inside the Claude connector, and\n its version tracks the CLI release so updates land.\n- **Identity-gated.** Connector code requires `COTAL_NAME`, `COTAL_LINK` or `COTAL_AGENT_FILE`.\n A plain `claude` with none of them never joins, so your own sessions in a repo do not appear\n as stray peers. Its MCP server still answers `initialize` and lists one static tool,\n `cotal_how_to_join`, which explains how to launch a session on a mesh. It builds no mesh\n agent, opens no broker connection and binds no control socket.\n- **Hands-free.** The dev-channels flag prints a one-time confirm prompt. The PTY runtime waits for\n the dialog title in normalized terminal output and presses Enter once when it appears, so startup\n speed does not affect a supervised launch. If the declared prompt never appears, the seat exits\n with a bounded error naming the unmatched prompt instead of hanging silently.\n\nInbound mesh messages arrive in context as\n`<channel source=\"cotal\" from=\"bob\" kind=\"dm\" \u2026>\u2026</channel>`: each meta key a tag\nattribute the agent can read for routing.\n\n## How messages reach the session\n\nDurable deliveries land in the connector's inbox from JetStream consumers\n([SPEC \xA78](../SPEC.md#8-nats--jetstream-binding)); live channel traffic can instead arrive\nthrough an at-most-once core subscription. A durable message sent while the agent is busy\nor offline waits on the stream. Two things move a message from inbox to model; one\ndelivers, the other only wakes:\n\n- **Hook drain (delivery).** `SessionStart` / `UserPromptSubmit` hooks read automatic inbox items and\n inject them as `additionalContext`. This is the single authoritative path: deterministic and works\n on any Claude Code build. Quiet ambient is excluded and stays buffered for `cotal_inbox`.\n A message is **acked only once the hook reply carrying it has cleared both legs of its journey**:\n the connector's control socket to the hook process (which gives up after 2s), and the hook\n process's own stdout to Claude Code (which it force-exits 1s after starting to write). The relay\n sends a receipt back down the control socket from that stdout write's callback, and only on a\n clean write (a runtime whose pipe has gone away fails it), and the connector treats that receipt,\n not its own socket write, as delivery. So a large injection killed mid-flush, or one written to a\n broken pipe, leaves the message un-acked and JetStream redelivers it. What this does *not* prove is\n that Claude Code read or applied the reply: a payload small enough to fit the pipe buffer is\n reported written the moment the kernel takes it. That residual is why the path errs toward\n at-least-once rather than treating a confirmed write as a confirmed read. Acking when\n the reply was merely *formatted* meant a lost reply was a lost message: it was already marked\n handled, so its own redelivery was silently acked on arrival.\n A hook whose handler throws still returns an empty reply so the session is never blocked, and\n that reply carries nothing, so it commits nothing: the batch it had started to surface stays\n un-acked and goes out on a later frame. The seat also drops any `turn-pending` row that breaks\n the manager contract, such as one with no integer deadline, and says so once in its log. A reply\n with no `turns` array changes nothing: the seat keeps the turns it already holds.\n This errs toward **at-least-once**: if a reply lands but its confirmation does not, the batch is\n surfaced again and flagged as a possible repeat. A duplicate injection is noise; a buried DM stops\n the peer answering at all.\n- **Channel nudge (wake).** An arriving message fires a `notifications/claude/channel`\n event that wakes an *idle* session into a turn, so the drain runs *now* instead of at\n the next prompt. The nudge never acks anything. A nudge that the host rejects is retried with a\n bounded backoff while anything is still pending. For an idle session it is the only wake source,\n so dropping it means silence until someone types. When the channel becomes active, the connector\n first re-fires a focus mention remembered during startup, otherwise one buffered wake. A rejected\n push keeps its bounded retry, and JetStream redelivery remains the durable backstop for unacked\n inbox items. If the channel cannot run at all, delivery still waits for the next hook. Live-only\n traffic has no durable retry.\n\n**Two priority tiers.** A *directed* message (DM, anycast, or a channel message that\n`@mentions` us) always nudges. *Ambient* channel chatter does not nudge mid-turn; it\naccumulates, and the `Stop` \u2192 idle transition fires one batch nudge so the backlog drains\ntogether.\n\n**Constraints (accepted).** Channels are a Claude Code research preview (\u2265 v2.1.80;\npermission relay \u2265 v2.1.81): Anthropic auth only, admin-enabled on Team/Enterprise, and a\ncustom channel needs the `--dangerously-load-development-channels` launch flag. The hook\ndrain does not depend on any of that; the channel only adds \"wake me when idle.\"\n\nThe same channel also relays **tool-permission requests** onto the mesh, so a peer (a\nhuman at the CLI, a policy node) can approve or deny an agent's pending tool call through\nCotal rather than a per-terminal prompt.\n\n### Attention\n\nAn agent picks how aggressively peer traffic reaches it with\n`cotal_status({ attention })` (three modes, orthogonal to presence):\n\n| arrival | open (default) | dnd | focus |\n|---|---|---|---|\n| directed (dm / anycast) | wake + inject | wake + inject | wake + inject |\n| channel `@mention` | wake + inject | wake + inject | ack-drop; wake to *pull*; not injected |\n| ambient channel chatter | wake when idle; hold while working | never wakes; injects next turn | ack-drop; recall via `cotal_inbox` |\n\nPer-channel overrides refine this: **quiet** (delivered, never wakes; `@mention` still\nwakes) and **muted** (dropped on receive, mentions included; DMs/anycast unaffected), set\nwith `cotal_channel_mode` or as agent-file defaults (`quiet:` / `muted:`,\n[agent files](agent-files.md)). A per-channel override is the final word for that channel.\nQuiet ambient is pull-only: it never hitchhikes on a human prompt, DM, mention, or other\nconnector-driven turn. `cotal_inbox` explicitly surfaces and clears it. A quiet-channel\n`@mention` remains automatic and injects normally.\n\nA pull is bounded too, and clears only what it hands over. One `cotal_inbox` call carries at most a\nreceivable window (direct messages and role requests first, then channel traffic, replayed history\nlast); whatever does not fit stays buffered, is named in the reply, and comes back on the next call.\nA message too large for one whole response is delivered in parts: once no smaller mail is waiting,\neach call carries its next part, and it is cleared only after its last part goes out, because clearing\nwhat was not handed over is the loss this bound exists to stop.\nThat matters most on the path where it is easiest to lose mail: reconnecting brings a channel-history\nreplay with it, so the largest payload and the least expendable message arrive in the same read.\n\nThe local inbox is bounded. On pathological overflow it evicts pull-only items before automatic\ntraffic. If the bounded live/durable classification guard also fills, the connector fails closed:\notherwise-normal ambient becomes pull-only until restart. Muted hard-drop and normal focus recall\nstill take precedence. Focus also keeps a bounded exclusion list so mode toggles cannot recall\nquiet/muted traffic; if that safety bound fills, recall skips the affected channel and reports it\nas incomplete rather than risk resurfacing excluded content. Recall cannot tell one message with an\nempty id from an identical one with another disposition, so in focus such a message is held in the\nlocal inbox as pull-only instead of being dropped, and a mention of it still wakes the agent. When\nthe session settles an id-less copy, it reads the chat stream's last sequence. Identical copies\narrive in stream order, and those reads can answer out of order, so the first read that can see the\ncopies binds them in arrival order, latest first, each to the latest unbound stream copy at or below\nthe lowest sequence read for it or any later identical copy.\nWhile the connection stays up, every stream copy at or below that sequence reached the session\nfirst, so a later identical copy sent during a reconnect gap is above it and stays unbound. A copy\nthat arrives while a read runs may not be in that read, so it binds nothing there and recall reads\nthe channel again, up to three times; if it still could be a stream copy in the last read, recall\nleaves that stream copy in the stream and reports the channel as incomplete. A settled copy a\ncomplete read cannot bind is behind the focus start or out of retention, and is forgotten. Recall\nhands back into the inbox only the stream copies nothing is bound to, such as one sent during a\nreconnect gap or one the inbox evicted on overflow, and `cotal_inbox` hands each over once. Overflow\nfrees only the evicted copy, held or quiet, and a copy no read has bound yet keeps its place in\narrival order, so an identical muted copy stays out of recall. When\nthe inbox is full, recall leaves them in the stream for a later call and reports the channel as\nincomplete. A history read that fails, or a channel with replay off, settles nothing and is reported\nas incomplete, and recall calls run one at a time. If the sequence read for a settled copy fails,\nor answers only after the connection dropped, recall skips that channel for the rest of the focus\nperiod and reports it as incomplete. Recall cannot tell a late copy of a message it handed back from\na new identical message, so every copy takes its own disposition: a new identical quiet mention is\nstill delivered automatically, and a late copy can surface a second time.\nA recalled message that already went out in part is read to its last part, even if an exclusion\nlands after its first part. One session reads its inbox one call at a time: a `cotal_inbox` call\nthat overlaps another waits for it to finish, so neither decides from a view the other has already\nmoved past.\nIf the separate hard-drop disposition guard fills, channel traffic is dropped for the rest of the\nsession rather than risk a late copy bypassing an earlier muted/focus decision; DMs and anycast are\nunaffected.\n\nAttention is **advisory UX, not a boundary**: any peer can wake a dnd/focus agent by\nnaming it, and `muted` means \"I opted out of receiving\", not \"the channel is blocked\";\nthe broker still authorizes and delivers. Focus's real effect is shrinking the\nuntrusted-ambient injection surface (only subject-authenticated dm/anycast auto-inject).\nIt resets to **open** on `SessionStart`, so a restarted agent never stays silently deaf.\nYour attention is mirrored into presence so peers can see it.\n\nWhatever does reach a turn is framed so a peer cannot write the frame. A line that begins at column\nzero is written by the connector; one message is one line plus indented continuations, with the\nsender inside a single bracket pair. A message body, a sender name and role, and a service or\nchannel label are all peer-controlled, so each passes through the same neutralization the\n`cotal_inbox` reply uses: no line break a splitter may honour and no bracket survives into a\nrendered attribution. This matters more for an injected block than for a reply, because the agent\ndid not ask for it and so never had the chance to distrust it.\n\n## Presence mapping\n\nThe connector wires a small subset of Claude Code hooks to presence states; presence is\ncoarse, and \"what it is doing\" rides on activity updates. Presence is **advisory**: a presence\npublish that fails (the endpoint mid-reconnect, say) is swallowed and never prevents the same hook\nfrom delivering messages or flushing held ones.\nA `SessionStart` during an open turn, including compaction, preserves the current `working` or\n`waiting` status until `Stop`, `StopFailure`, or `SessionEnd` closes the turn.\n\n| Hook | \u2192 state |\n|---|---|\n| `SessionStart` | `idle` only when no turn is open (join; surfaces the inbox; captures the live model into `meta.model` when no pin) |\n| `UserPromptSubmit` | `working` (turn starts; surfaces the inbox) |\n| `PreToolUse` | no change; records *what* is about to run, so a permission wait can name it |\n| `Notification` (`permission_prompt` / `agent_needs_input`) | `waiting` with condition `approval` / `input` (activity leads with the pending tool, e.g. `Bash: git push \u2026`) |\n| `Stop` / `StopFailure` | `idle` (turn done / died on an API error; flushes anything held while busy). `StopFailure` also relays Claude Code's native error value as `condition.source` and maps it to the closed condition vocabulary. On the [event plane](#event-plane) it closes the run with `RUN_ERROR`. |\n| `SessionEnd` | `offline` (graceful leave) |\n\n`StopFailure` maps `rate_limit` and `overloaded` directly; auth and credential failures to\n`auth`; account and billing failures to `billing`; `invalid_request` to `request`;\n`model_not_found` to `model`; `server_error` to `server`; `max_output_tokens` to `context`; and\n`unknown` to `failed`. The native value remains in `condition.source`.\n\nHooks are relayed over the connector's **authenticated** local control endpoint (per-user\nsocket + per-launch token, constant-time checked), so a local process that finds the path\nstill can't drive presence or stop the agent. The full Claude Code hook-event list lives\nwith the adapter:\n[`extensions/connector-claude-code`](../extensions/connector-claude-code/README.md).\n\n## Event plane\n\nA spawned session publishes a **structured** account of what it\ndid: run boundaries per turn, assistant text, reasoning, and each tool call with its start\nand its end. Not prose about the work, the work itself, in a vocabulary a program can\nread. The launcher sets `COTAL_EVENTS` by default; pass `--no-events` to opt out on an unrestricted\nspace. A user-auth registration with `policy: { events: \"required\" }` carries `eventsRequired` in the\nprivate launch material, so the connector arms even without `COTAL_EVENTS`; `--no-events` is refused.\nA hand-driven user-mode session may carry the same decision as `COTAL_EVENTS_REQUIRED=1`. Its own\npublish grant must cover `events.<owner>.<actor>` or the connector refuses before joining. An unmanaged\nsession with no launch material and no required-policy fallback keeps the generic default behavior.\n\nA new session includes its first run even when Claude writes a positional startup prompt before the\nconnector receives `SessionStart`. That from-zero read is keyed only to Claude's explicit\n`source: \"startup\"`; resumed, forked, cleared, and compacted sessions adopt at the transcript boundary\ncaptured at that adopt, before the mesh link connects, so nothing Claude appends while the connector\nis still starting up lands behind the cursor and is silently dropped. Crash recovery follows the\ncursor already stored in the event write-ahead log, regardless of the new process's startup label.\n\nClaude starts each hook in its own process, so a prompt or stop relay can reach Cotal before the\n`SessionStart` relay. The connector holds those event flushes and the terminal until `SessionStart`\nsupplies the source, then enqueues adopt, flush, and close in that order.\n\n`SessionStart` can also run before the connector process has bound its local control socket. The hook\nthe `SessionStart` relay retries only transient pre-connect listener errors, with capped backoff\ninside its existing two-second budget. Later hooks and permanent local faults still fail open\nimmediately. Once a socket has connected, a broken exchange is not retried: the connector may\nalready have handled the frame, so replaying it could apply one lifecycle event twice.\nThat retained `SessionStart` can itself arrive before Claude creates the transcript path. A genuinely\nnew startup waits up to five seconds for that file with capped backoff, and the same deadline bounds\none stalled file read; expiry fails loud instead of silently losing the first run. A forked session\ngets the same wait, because Claude copies the parent transcript into the fork's own file after the\nhook, and then adopts at the end of that copy. Resumed, cleared and compacted starts and recovered\ncursors still require their existing source at once.\n\nTool arguments (`TOOL_CALL_ARGS`) and tool results (`TOOL_CALL_RESULT`) are not republished\nonto this channel. The durable emitter drops those events before they are written to the\nwrite-ahead log, because this channel's read ACL is not the ACL the tool ran under. Content is\nmandatory on both kinds, so the event is suppressed rather than emptied or replaced with a\nplaceholder. Tool start and end still go out. A restart that finds a pending pre-fix frame\nstill carrying those kinds HALTS rather than republishing it.\n\nThe channel is **`events.<owner>.<actor>`**, named after the session's principal. What the actor\nhalf is depends on the mesh, and the difference matters when you go looking for it: on a static mesh\nit is a key the manager allocated, never the display name, so two live agents sharing a display name\ndo not share a stream; on a user-auth mesh it is the agent's own name, because that is what the\nledger row is keyed on. Spelled out again with both halves below. The launch grants publish rights\non that channel alone. A spawn\nthat asks for a *different* agent's event channel is refused at the door rather than granted, since\nthat channel is that session's event stream. The same rule runs on restart: a manager\nresume document that names another agent's event channel is refused rather than adopted, because the\nmanaged row is re-armed from that document and the credential is re-minted from the row.\n\nThe rule reads a **concrete** channel, two principal tokens and nothing else. A pattern such as\n`events.<owner>.>` is not an event channel to it and passes untouched, governed by ordinary ACL\nauthority: on a user mesh the delegation envelope, on a static mesh the spawning credential itself.\nThat is deliberate, because the pattern is the form an operator writes on purpose for an observer,\nand it is worth knowing rather than assuming the fence is total.\n\nTo let something else read a plane, grant it out of band. The refusal prints the command for the\nmesh it is running on, spelled out in full, and only that one.\n\nOn a **user-auth** mesh:\n\n```bash\ncotal actor grant <reader> --owner <owner> --scope '' --allow-subscribe 'events.<owner>.<actor>' --allow-publish ''\n```\n\nEvery field, deliberately. `actor grant` is an upsert of the whole row, so it refuses a grant that\nleaves off any of the three ACL flags. Only `--full` turns an omitted flag into the wide default\n(`>` read, `>` post, `spawn,role:default` scope), which is the opposite of what a scoped watcher is for.\n\nOn a **static** mesh there is no actor ledger for `actor grant` to write to, and the refusal says\nso; mint the reader instead:\n\n```bash\ncotal mint watcher --profile agent --allow-subscribe 'events.<owner>.<actor>' --provision\n```\n\nThe **agent** profile, not the observer one. `mint` reads `--allow-subscribe` only for that\nprofile, and refuses it anywhere else: `--profile observer --allow-subscribe <channel>` exits\nnon-zero and writes no creds file, because the observer profile carries a fixed read set over the\nwhole chat plane, which is the opposite of what a scoped watcher is for. The agent profile also prints the lifecycle uid the\nreader needs, since an authed consuming endpoint refuses to start without one.\n\nOn an **open** mesh there is nothing to grant: the mesh has no credentials and no ACLs, so any peer\nthat lists the channel reads it, and the refusal says so instead of naming a command. The\nown-channel rule still applies there, because a spawn is not the place to hand out a read on\nanother agent's tool inputs and outputs.\n\nTwo things a reader has to do that are not obvious, both on `CotalEndpoint`. It must pass the event\nchannel in `channels`: an endpoint reads the channels it lists, so one constructed without\nthe event channel joins nothing and the frames never arrive. And it reads history with `readHistory(channel)`, the delivery daemon's mediated read, not\n`channelHistory(channel)`: a scoped credential is denied the ad-hoc consumer the direct read\ncreates, by design. `cotal console` and the web console already do both.\n\nThe `<owner>.<actor>` pair is the session's principal. On a user-auth mesh the actor half is the\nagent's own name, so the channel is `events.<your-owner>.<agent-name>`. On a\nstatic mesh the owner half is the literal `local` and the actor is a key the manager allocated, so\nthe channel is `events.local.<key>`; the spawn reply carries that key as `id`. Note\nthat `cotal console` and the web console keep event channels out of their channel lists on purpose,\nsince a plane is a machine feed rather than a conversation; they draw the frames when you open the\nchannel by name.\n\nThe rule governs the manager's doors, which are the ones a caller other than you can reach. A\nforeground `cotal spawn` on your own machine mints from your own signing material, so it can still\ngrant any channel you name: that is the out-of-band grant, not a way around the rule.\n\n**Failed turns publish run errors.** Claude Code decides for itself\nwhether a turn finished or died and fires one of two hooks accordingly, so the connector relays that\ndecision rather than making one of its own: a turn that ended on an API error ends its run with\n`RUN_ERROR` carrying the fixed message `run failed` and no code. Neither the detail Claude Code\nreported nor its error kind is published there: both are upstream values that can echo your prompt or\ntool output, and the events channel has a different read ACL. The error kind still reaches presence\nas the agent's condition (`rate_limit`, `auth`, `billing` and the rest). A turn that ended normally still\nends with a run-finished event carrying no outcome, which says the turn ended and does not claim it\nsucceeded.\n\nEvents are written to a per-session write-ahead log before they are published, so a hook that fires\nafter a restart resumes at the cursor it left rather than replaying or skipping, and a run that was\nopen when the session stopped is closed rather than left dangling.\n\nOne channel carries **every session of one agent**, because it is named after the principal and not\nafter the session. Alongside the per-session logs the connector keeps one small record per principal,\nholding the last sequence the broker assigned on that channel, so a new session continues the stream\nits predecessor left instead of starting again from nothing. Both live under the events state root\n(`COTAL_WORKSPACE_ROOT`), and neither is something you edit by hand.\n\nA **missing** record is not a fault: the connector rebuilds it from the session logs beside it,\nwhich is how an agent that was already running before this record existed keeps its stream. That\nrebuild stops if any one of those session logs is damaged. Unreadable, not valid JSON, and written\nfor a different principal all count, and so does a session directory or a log that is a link rather\nthan the real file the connector wrote, or a log that has more than one name. A tip taken from the\nrest would be too low, and it would stop publication later with nothing left to point at the cause.\nThe connector names the file instead, and the only way past it is the directory removal described\nbelow, under the same condition. A record that **disagrees with the broker** is a fault, and the\nconnector stops publishing and says why rather than guessing. A record that **moved while a session\nwas writing to it** is refused the same way: it means something else wrote the principal's record,\nand the connector reports which value it held and which the file holds rather than writing over the\nlater one. There is no command to clear it. The state is the principal's directory under the events\nroot, and clearing it by hand means removing that directory whole: the sequence, the cursor and the\nper-session logs only mean anything together, so removing part of it leaves a state the next start\nrefuses. Removing it is only half a remedy, and the half that comes first is the channel. The\ndirectory is where the agent's memory of the tip lives, not the tip itself, so on a channel that\nstill holds frames the next session opens expecting an empty one and stops on the same\ndisagreement, with the logs a tip could have been rebuilt from now gone. Purge the channel first,\nthen remove the directory.\n\nReading it: `cotal console` and the web console draw event frames directly. A frame carries no text\npart by design, so a surface that renders a message as flat text shows a marker instead of prose.\n\n**On a per-user-auth mesh, the default event plane needs the spawner's grant to cover the channel.** The event\nchannel is added to the child's publish set, and delegation only narrows: an agent may hand down\na subset of what it holds and no more. So a peer-initiated spawn is refused unless the\nspawning identity's own grant already covers the child's event channel. The refusal prints the\nexact `cotal actor grant` command that widens it. An operator launch, whose chain reaches an\nadmin-scoped or roster row, is unaffected. Passing `events: false` is the explicit opt-out.\n\nArming the event plane through a typed spawn request (`manager.spawn` with `events`, including\nthe CLI's `cotal spawn --detach --events`) additionally requires the caller's admin tier on a\nuser mesh. A non-admin caller that asks for the plane is refused before anything is provisioned,\nand one that stays silent gets a spawn without it, with the reply saying so.\n\n## Resume a session\n\n`--resume <session-id>` pulls an existing Claude session, its context and transcript,\ninto the mesh. It **forks**: Claude mints a *new* session id from that transcript\n(`--resume <id> --fork-session`), so the meshed agent gets its own session and the\noriginal is untouched.\n\n- `cotal spawn --resume <id>` (foreground) is the primary surface: the transcript is on\n *your* machine, and errors are Claude's own stderr, inline.\n- `--detach --resume <id>` works, with two differences: the id resolves against the\n **manager host's** `~/.claude` (you practically need `--cwd`), and the manager waits for\n a real outcome; `\u2713 started` means the agent *joined the mesh*, `\u2717 exited on launch`\n carries Claude's last output, and an uncertain launch (~30 s) is reported without\n tearing the agent down.\n- Resume is an **operator surface only**, deliberately not exposed on MCP `cotal_spawn`\n (a mesh peer naming host-local transcripts would widen `spawn` into transcript\n disclosure). Only the Claude connector supports it today; OpenCode and Hermes fail loud.\n- Needs a `claude` new enough for `--resume \u2026 --fork-session` (verified on 2.1.197).\n\n## Sharing your MCP servers\n\nIsolation is the default, but a meshed teammate sometimes genuinely needs one of your own\ntools (say, web search). The opt-in is the cotal config file\n(`~/.config/cotal/config.json`, or a space-local `.cotal/config.json` layered on top):\neach entry the familiar `.mcp.json` shape, secrets written as `${VAR}` references, never\nliterals ([full format](config.md)).\n\nAt launch the connector forwards *only* the named vars the chosen servers declare and\npasses the merged config as an owner-only temp file; `--strict-mcp-config` stays on, so\nonly cotal + the explicitly shared servers load. Scope per spawn with\n`--share-tools tavily,figma` (or `--share-tools none`).\n\nTwo caveats: sharing a server grants its credential to the agent (the var lives in the\nClaude process's environment, so share only when you're fine with that teammate holding\nthe key), and memory adds up, because a heavy server boots once per spawn, multiplied\nacross a team.\n\n## Feedback\n\n`cotal_feedback` works out of the box: without a key it posts to the public intake at\n`https://cotal.ai/v1/feedback` (needs a contact email: `COTAL_FEEDBACK_EMAIL`, then\n`git config user.email`, else the agent asks). Set `COTAL_FEEDBACK_KEY=fbk_<key>` in a\nbeta tester's environment to route to the keyed intake (`Authorization: Bearer`, identity\nderived from the key); `COTAL_FEEDBACK_URL` overrides either endpoint. The CLI can send\ntoo: `cotal feedback \"<summary>\" [--type bug]`. Each submission carries\n`origin: human | agent`, whether the tester asked, or the agent auto-reported a major\nissue.\n"
19562
19562
  },
19563
19563
  {
19564
19564
  "slug": "connect-codex",
@@ -19635,7 +19635,7 @@ var DOCS_BUNDLE = {
19635
19635
  "title": "Embedding Cotal",
19636
19636
  "kind": "Guide (informative)",
19637
19637
  "summary": "The cotal binary in this repo is one composition root: an operator CLI.",
19638
- "body": "# Embedding Cotal\n\n> **Guide** (informative) \xB7 **For:** implementers building a service on top of Cotal \xB7 **Prereqs:** [Architecture](architecture.md), [Identity and auth](identity-and-auth.md), [Delivery daemon](delivery-daemon.md)\n\nThe `cotal` binary in this repo is one composition root: an operator CLI. A separate service\n(for example a hosted, multi-tenant Cotal) does not fork this repo. It writes its **own**\ncomposition root that depends on the published `@cotal-ai/*` packages and imports the surfaces it\nwants. `bin/cotal.ts` uses the same composition pattern. This page is the contract for that: what is a real library\nexport you can build against, how to boot the server-side daemons from those exports, and where the\ncurrent export surface stops short of a fully hosted composition.\n\nThis is the \"guarded substrate\" boundary in practice. Nothing here reveals or assumes a specific\nhost; it documents the public seams any embedder composes.\n\n## What you embed\n\nThe supported reference shape here is **one broker operator serving one space** (one tenant: a\ndedicated data account, under an operator that also holds the system account and a quarantined\nauth-callout account) plus three standalone processes. The trust layer itself composes many spaces\nunder one broker operator today (`createBrokerAuth` + `createSpaceAccountAuth` + N-space\n`serverConfig`); what does not exist yet is the per-space **lifecycle** on a shared broker (see\n[Known gaps](#hosted-composition-gaps)). The three processes:\n\n| daemon | package | what it is |\n|---|---|---|\n| auth-service | `@cotal-ai/auth` | the NATS auth callout, the IdP token exchange, and JWKS. Plane 1 to Plane 2. |\n| delivery | `@cotal-ai/delivery` | the Plane-3 durable backstop: fan-out writer plus trusted reader, per space. |\n| supervise | `@cotal-ai/manager` | the per-machine agent lifecycle (spawn/despawn/attach), per space. |\n\n`mint`, `deliver`, and `auth-service` expose their behavior as direct library primitives, and the\nsupported one-space bootstrap below re-composes from exported low-level primitives. `supervise` and\nthe full `up` orchestration are **not** public runners: `up` also does broker bring-up, restore,\nprocess and registry management, and lifecycle work, and `supervise`'s orchestration is private (see\n[Supervisor signing authority](#supervisor-signing-authority)).\n\n## The export surface\n\nEverything below is a real export of a published package, reachable from the package root (each\npackage publishes only `.` via `dist/index.{js,d.ts}` and ships `files: [\"dist\"]`). Type-only names\nare marked; import them with `import type`.\n\n**Daemon runners and lifecycle**\n\n| symbol | package | purpose |\n|---|---|---|\n| `runAuthService(args, store?)` | `@cotal-ai/auth` | boot the auth-service daemon; `store` injects the secret material. |\n| `runDelivery(args, store?)` | `@cotal-ai/delivery` | boot the delivery daemon; `store` injects the scoped `delivery` cred. |\n| `startAuthService(inputs)` | `@cotal-ai/auth` | start one account-scoped auth-service context and return an `AuthServiceHandle` with the loopback `url`, `readiness`, `drain`, and idempotent `close`. With the optional `platformControl` input the handle also has `platformControlAuthority`, the in-process platform control door. `runAuthService` remains the CLI entry. |\n| `PlatformControlAuthorityRequest`, `PlatformControlInnerRequest`, `PlatformControlAuthorityResult`, `PlatformControlAssignment` *(types)* | `@cotal-ai/core` | the closed envelope, its inner request union, its result and the backend's assignment row for `platformControlAuthority`. `platformControlOwner` in `@cotal-ai/auth` derives the `p_` owner the door issues under. |\n| `startDeliveryService(inputs)` | `@cotal-ai/delivery` | start one account-scoped delivery instance and return a `HostedServiceHandle` with `readiness`, `drain`, and idempotent `close`. The process runner remains the CLI entry. |\n| `deliveryCredsKey(space, composition)`, `membershipRwCredsKey(space, composition)` | `@cotal-ai/workspace` | build the secret-store keys the delivery cred and the membership feed's rw cred are read/re-signed under. Keys are **per-space**: `space.<hex>/<kind>`. A hosted composition passes `{ injected: true }`. |\n| `retireManagerInstanceIdentity(root, space, expected)` | `@cotal-ai/workspace` | remove a persisted manager identity only if its complete instance id and serve identity still match `expected`. Returns `removed` or `absent`; refuses malformed, nonregular, and changed records. `absent` is not proof of ownership or successful teardown. The caller must separately prove stop and retirement ownership before using it. |\n| `DELIVERY_CREDS_KIND`, `MEMBERSHIP_RW_CREDS_KIND` | `@cotal-ai/workspace` | the operator-facing KIND names (`delivery.creds`, `membership-rw.creds`) those keys are built from, and what renewal results report. A kind is **not** a key: putting a cred under the bare kind writes the pre-0.4 flat location, which nothing reads. |\n| `Manager`, `ManagerOptions` *(type)* | `@cotal-ai/manager` | construct and run a supervisor in-process; `ManagerOptions.secretStore` injects the one store it reads/writes every secret through. `ManagerOptions.remoteAuthority` is the hosted manager-service authority bundle, including host-owned release, retained-validation, goal-index, and serve-time admin-authorization callbacks. |\n| `ManagerOptions.pooled` | `@cotal-ai/manager` | require signerless remote authority and an explicit non-custodial runtime before local execution starts. A pooled composition must supply the assigned account key and all-duty renewal callback; the CLI's default remains unchanged. A signed-in human's manager gets that material from `managerServiceAuthority`. A platform-run control manager gets it from `AuthServiceHandle.platformControlAuthority` with the shipped `remoteManagerClient` builders, as the [platform control authority](https://github.com/Cotal-AI/Cotal/blob/main/docs/design/platform-pooled-control-authority.md) design describes. |\n| `createRuntime`, `Runtime` *(type)* | `@cotal-ai/manager` | resolve the spawn backend (pty built in). |\n| `liveKvEntries(kv, filterOrOptions?, options?)`, `LiveKvEntriesOptions` *(type)* | `@cotal-ai/core` | read live KV entries in one finite scan. Pass `{ signal }` as the second argument or after a key filter to cancel. An interrupted scan throws `IncompleteKvScan`; cancellation throws the signal reason, including during an empty-bucket bind. The scan deletes only its owned consumer; broker inactivity expiry remains the crash or deletion-failure backstop. |\n\nThe remote manager authority parser accepts `renewStandingBundle` and `renewRunDriver` only with\nan assigned account nkey, the current manager process epoch, and a host-authenticated registration\nproof. A run renewal also names its active holder, takeover, epoch, fencing token, and the two\nexisting nkeys. The host must fresh-check those coordinates against its registration gate and run\njournal before issuing server-selected profiles. A host without that renewal authorization refuses\nthe request. Until the host issuer wires the operations and validates them on real connections,\nthe presence of these types is not an operational pooled renewal guarantee.\n\nWith `renewStandingBundle` configured, the manager renews all five standing credentials together.\nIt checks every returned credential for the held nkey and assigned account, test-connects each one,\nand adopts them only if the serve epoch has not moved. A refused or failed candidate leaves the\ncurrent credentials in place and records the refusal as cleanup debt until a later renewal succeeds.\nOn shutdown, after the standing context is drained, an expired maintenance executor is renewed\nthrough the existing scoped host operation so deregistration can finish without restarting duties.\n\n**Provisioning and minting** (all `@cotal-ai/core`)\n\n| symbol | purpose |\n|---|---|\n| `createBrokerAuth(label)` | mint BROKER trust: the operator and system account one nats-server trusts. One per broker, shared by every space on it. |\n| `createSpaceAccountAuth(broker, space)` | mint one space's own data account, signed by that broker's operator: the add-a-tenant primitive. |\n| `createSpaceAuth(space)` | the one-space convenience: broker trust + one account in a single composed bundle. |\n| `setupSpaceStreams({ servers, space, creds })` | create the space's JetStream streams. |\n| `ensureDefaultDeliveryClass({ servers, space, creds?, deliveryClass })` | write the space's default delivery class at creation so it is wire-discoverable (SPEC section 4). |\n| `serverConfig(broker, spaces, { storeDir, maxFileStore?, extraAccounts?, port?, host? })` | render the broker config: one operator, N space accounts. `storeDir` is required, `maxFileStore` caps JetStream file storage in bytes (omitted, nats-server's dynamic default applies), and `extraAccounts` preloads the auth-callout account. |\n| `mintCreds(auth, identity, profile, opts?)` | mint a scoped cred for any `Profile`. |\n| `mintMembershipObserverCreds`, `mintConnectionEvictorCreds` | mint the membership/eviction scoped creds. |\n| `provisionAgent`, `provisionAgentDurables` | create a principal's bind-only durables. |\n| `newIdentity`, `stripSpaceAuth` | a fresh nkey identity; a stripped signer bundle (data signing seed only). |\n| `Profile`, `CredentialKind`, `MintOpts`, `SpaceAuth` *(types)*, `CREDENTIAL_LIFETIMES` | the profile matrix and cred lifetime policy. |\n\n**Auth building blocks** (all `@cotal-ai/auth`)\n\n| symbol | purpose |\n|---|---|\n| `createCalloutAuth`, `startAuthCallout` | the NATS auth-callout responder. |\n| `createUserTokenIssuer`, `pinnedJwksResolver` | mint and verify the Cotal user bearer. |\n| `createIdpBridge` | exchange a verified IdP JWT for a Cotal bearer (see [the callout contract](identity-and-auth.md#the-idp-callout-contract)). |\n| `deriveOwnerToken`, `validateUserToken` | owner derivation; strict bearer validation. |\n| `cotalAuthProvider` | the self-registering `auth-provider` extension. |\n| `ensureCalloutAuth`/`loadCalloutAuth`, `ensureIssuer`/`loadIssuer`, `ensureOwnerSecret`/`loadOwnerSecret` | read/write the auth secret kinds through a `SecretStore`. |\n\n**Seams and the wire** (all `@cotal-ai/core` unless noted)\n\n| symbol | purpose |\n|---|---|\n| `SecretStore` *(type)* | the durable hosted-secret seam (get/put/delete); `get()` returns raw seeds/keys into process memory, so it is a blob seam, not HSM/KMS signing. |\n| `FsSecretStore`, `workspaceSecretStore(root)` | the filesystem default. **These live in `@cotal-ai/workspace`, not core.** |\n| `AuthProvider` *(type)*, `Connector` *(type)*, `Runtime` *(type)*, `Command` *(type)* | the extension contracts; implementations self-register on import. |\n| `registry` | the shared registry a composition root pulls surfaces into. |\n| `CotalEndpoint`, subjects, message types | the wire client and shapes. |\n| `ParsedArgs` *(type)* | the shape the daemon runners take (see below). |\n\nFor a Linux Unix-socket adapter, `peerCredentials(socket)` from `@cotal-ai/seat` returns\nkernel-observed peer `pid`, `uid` and `gid`. Compare these against the host's authorization\npolicy; request-supplied identity and process liveness do not replace that policy or a\nlifecycle fence. The helper starts no custodian and refuses unsupported platforms or a\nmissing native helper.\n\nThe runners take a CLI-shaped `ParsedArgs`, not a typed options object, so a host fabricates one:\n\n```ts\nconst args: ParsedArgs = { values: { space, server, port: \"0\" }, positionals: [], raw: [] };\n```\n\nFor an embedded delivery instance, use `startDeliveryService` instead. Its `HostedContextInputs`\ninclude the account public key and lifecycle UID, space, broker URL, injected store, stable\n`storeIdentity`, and an explicit `stateDir`. The store must declare that same injected identity.\nThe initial delivery credential must belong to the assigned account. The function returns only\nafter the delivery responder is bound. `close()` withdraws serving and releases only the lease\nowned by that instance. It closes both membership connections even when a disconnected drain\nfails, so they cannot reconnect after closure. Credential-expiry health state clears after\nsuccessful broker-verified adoption through the existing `reloadCreds` rail. A failed start\nrefuses locally without exiting the host process or stopping another account's delivery service.\nIf a health fault occurs during an asynchronous store read, startup rejects when the read returns\nand closes any resources created by that late completion.\n\n`startAuthService` takes the same `HostedContextInputs`. The store must declare the assigned\ninjected identity, and its data account must be the assigned account. The IdP pin and ledger live\nunder the explicit `stateDir`. The context never resolves a workspace root from the working\ndirectory and has no local manager, so only remote manager gates can be selected. It returns after\nthe authority plane, the callout subscription and the loopback listener are bound. A fenced plane or\na lost broker connection makes that context `unavailable` and closes it without exiting the process.\nThe host writes no discovery file for it.\n\nTwo optional inputs serve a platform composition. `platformControl: { observeAssignment }` adds\n`platformControlAuthority` to the handle. It is a typed in-process method, served on no listener,\nthat issues the manager-service request family for the one control manager the backend assigned to\nthis account, under a derived `p_` owner. It reads the assignment fresh on every call and refuses\nan IdP token, another account, a stale revision, another instance or lifecycle, and an instance\nanother owner registered. It refuses `prepare` and `activate` while the assignment's named\npredecessor is still registered or frozen. Without the input the member is `undefined`.\n`standingRenewableTtlSeconds` is forwarded unchanged to the authority plane, which bounds it to 5\nto 86400 seconds. It is a trusted-host input for the renewal rehearsal, and no request or CLI flag\nsets it. SPEC \xA713.1 and \xA713.6 define the view.\n\nThe auth plane can renew a registered manager's\nfive standing credentials from the current service registration. Run-driver renewal still\nrefuses without an authoritative activated-run reader, so these handles do not yet make a\ncomplete pooled auth and delivery host. A fresh auth plane can initialize without a\ndelivery-admin responder. Reclaiming a held claim from a dead predecessor needs the delivery\ninstance first: its admin rail must complete the broker connection-liveness sweep before the\nauth plane takes the claim. An absent or inconclusive oracle refuses the reclaim.\n\n### Remote manager client composition\n\n`@cotal-ai/manager` exports the `remoteManagerClient` namespace, containing the stock remote\nrequest builders and response validators, and `registerRemoteManagerAuthority` for registration\nwith a host-issued prepare credential. `RemoteManagerIdentityState` describes the five private\nmanager identities stored under an explicit account-local root. Use these public exports when\ncomposing `ManagerOptions.remoteAuthority`; do not copy CLI validators or import private modules.\n`managerClusterArtifacts()` returns the canonical document, manifest and their digests used by\nregistration. Supply its `[document, manifest]` pair when constructing the activation request\nwith `remoteManagerClient.remoteManagerAuthorityRequest` and the stock registration proof.\nThe host still validates the artifact closure and current registration before activation.\nThe namespace includes closed standing/run renewal, admission, maintenance, enrollment and\nretirement helpers. It provides no signer or new grant. The host still owns authenticated issuance,\ncurrent registration and activated-run observations, and any guarded foreign-holder repair.\nAn embedding must preserve those checks and supply a supported runtime; the client exports alone\ndo not provide a pooled runtime, an authority service or a complete hosted context.\n\n### Long-lived endpoints take a bearer function\n\n`EndpointOptions.bearer` accepts either a string or a function, and the difference is not stylistic.\nA string is minted once, so when it expires (which it will: callout bearers live minutes) the\nendpoint has nothing to renew with. It will not present the dead token to the broker, since that is\na guaranteed denial that still costs a full auth-callout round trip. It refuses to reconnect, emits\n`warning` saying which case it is in, and retries on a widening backoff until the process\nre-authenticates and rebuilds it. Retry notices use `warning` rather than `error` because Node\nrethrows an unhandled `error` event and would kill a host the endpoint is still trying to recover.\n\nPass a **function** for anything that outlives one bearer. That is a renewal source: it is called\nahead of each expiry and again whenever a reconnect finds the cached bearer dead, and it requires\nexplicit `card.owner` and `card.actor`. The first-party surfaces already do this\n(`UserViewAuth.source`, the connector's `agentBearerCommand`). A string bearer is for a short\none-shot connection.\n\nLong-lived hosts must also subscribe to the endpoint's `warning` event. It carries conditions the\nendpoint is surviving, including failed credential renewal, reconnect retries, and a durable leave\nit keeps retrying after the broker refuses a durable channel's live subscription. A host may choose\nto ignore warnings for a one-shot endpoint whose awaited operation owns the verdict, but that choice\nshould be explicit. An unhandled warning is nonfatal and silent.\n\n## Booting the daemons\n\n### auth-service\n\n`runAuthService(args, store?)` reads its provisioned long-lived secret kinds (service keys, callout\naccount, issuer keys, owner secret) through the injected `SecretStore`; a host provisions those into\nthe store first. It is a **signer and identity authority**, not a scoped daemon: at runtime it holds\nthe data-account and callout-account signing seeds, the issuer's private JWKs, and the\nowner-derivation secret in process memory (`SecretStore.get` exports raw values). The IdP pin and the\nactor ledger are **not** store-injected: `runAuthService` resolves them under\n`userAuthStateDir(findCotalRoot(), space)`, a path relative to the process working directory, so a\nhost provisions those into that exact directory (neither `store` nor `COTAL_HOME` selects it). It\nalso writes an ephemeral `auth-service.json` discovery file there that carries the live exchange\ncapability. That file appears only after every plane is bound, so waiting on it is the readiness\nsignal: a host that also passes the daemon's pid to the provider's `ready()` gets a process-bound\nwait. The wait extends past the base timeout while that pid is alive, up to a fixed bound, and it\nends at once when the pid exits.\n\n```ts\nimport { runAuthService } from \"@cotal-ai/auth\";\n// store implements SecretStore over your secret backend; get() returns raw seeds into memory.\n// Provision the auth secret kinds into the store, AND the IdP pin + actor ledger under\n// userAuthStateDir(findCotalRoot(), space), before this call.\nawait runAuthService(\n { values: { space, server: brokerUrl, port: \"8081\" }, positionals: [], raw: [] },\n store,\n);\n```\n\n### delivery\n\n`runDelivery(args, store?)` runs from a **pre-minted scoped `delivery` cred** and never loads the\nsigner. Provide the cred either through the injected store (under\n`deliveryCredsKey(space, { injected: true })`) or with a\n`--creds` file; the two are mutually exclusive. The daemon re-fetches the cred from the store at 75%\nof its JWT lifetime and fails loud rather than riding to expiry, so **something must re-sign a fresh\ncred into that same store**. When that read finds the previous generation still there, the daemon\nreports the missed remint and retries in 60 seconds. The current cred stays live until its expiry,\nand the store is read once per retry rather than once per second.\n\n```ts\nimport { runDelivery } from \"@cotal-ai/delivery\";\nawait runDelivery({ values: { space, server: brokerUrl }, positionals: [], raw: [] }, store);\n```\n\nThat renewal is a **signer** operation, not the delivery daemon's:\n`remintDaemonCreds(root, space, store?, { preflight? })` (`@cotal-ai/workspace`) reads the `SpaceAuth`\nsigner **through the same resolved `store`** (`getSpaceAuth(store ?? workspaceSecretStore(root), space)`,\nkeys `auth/broker.json` + `auth/account.<key>.json`; the pre-split `auth/auth.json` monolith is\nmigration input and the container signer mount only) and re-signs the daemon creds (`delivery.creds` and the membership feed's\n`membership-rw.creds`) back into that store. The injected `store` is both the signer source and the\ncredential destination, never a split. `space` is **required** and validated against the store's signer, so a\nstore swapped to a different space cannot re-sign over the wrong broker's creds. `preflight` is a\ncaller-supplied proof that the broker accepts the credential. The reference `Manager` passes a\n`probeConnect` over its `servers`. It gates **every** candidate before overwriting the last-good,\nwhether the signer is a full bundle or a stripped projection: a bundle's JWT chain proves only that\nit is self-consistent and\nnamed the space, NOT that its account is the broker's *current* account for that space (two\n`createSpaceAuth(space)` calls yield same-named, different-account chains), so a same-label alternate\nsigner would otherwise mint a broker-dead cred and clobber the good one. The offline local repair (`doctor auth --fix`) has no preflight. It permits the overwrite only\nunder **authority continuity**: the candidate must be signed by the same account signing key (`iss`) as the current\n(already broker-accepted) cred. A same-label alternate account breaks continuity and is refused, full or\nstripped; a legitimate local re-sign is continuous and proceeds without a network. The reference\n`Manager` runs it on a schedule against its **own**\n`secretStore` (see below), so passing the manager and the delivery daemon the *same* store closes the\nrenewal loop end-to-end on an injected backend: the manager reads the signer from the store, re-signs\ninto it, and the daemon adopts each generation on a preflight-proven 75% timer. The stock\ncross-host composition cannot satisfy that by writing one filesystem and fingerprinting another:\n`Manager.start()` and every later remint challenge the daemon's `reloadStoreIdentity` and a\ndivergent pair is refused naming both stores. The identity is the store the daemon actually\nreloads: an injected coordinate, the workstation root only when `--creds` is\n`<root>/.cotal/<spaceSegment(space)>/delivery.creds` (matching the canonical arm), the\nfile's own directory for any other `--creds` path, or the workstation root. Uninjected\n`--creds` that names one real workstation while process cwd resolves another is refused\nat start, naming both, because membership-rw still uses `findCotalRoot`. A `--creds`\npath that is not under any `.cotal` tree is not that case and is not refused here. It never\nwalks ancestors with `findCotalRoot`. No bound daemon is not a named\nstore, so start proceeds; a later daemon on a foreign store is refused on the next remint.\nThe first-party filesystem adapter declares its workspace-root identity on the store itself. Other\ninjected adapters declare their stable coordinate on `SecretStore.identity`, or name it in\n`COTAL_SECRET_STORE` on both processes. It never throws: it\nreturns per-file results (`skipped: \"no-auth\"` when the store holds no signer records),\nso the caller must check them or the cred still rides to expiry. A composition whose signer lives in\nKMS/Vault simply injects that store; no bespoke renewal is needed. A `--creds` file path must be\nreplaced atomically before the 75% read. The signer can now be injected behind the store seam, which\nresolves custody. The remaining hosted gap is signer **isolation**. The seed is still decrypted\nin-process at the manager's uid, so it needs an OS sandbox or remote signer.\n\n### Supervisor signing authority\n\n`@cotal-ai/manager` exports the `Manager` class; there is **no** `runSupervise(opts)` runner. The\nprivate CLI `runManager` also does broker-reachability checks, space/default resolution,\nroster/launch parsing and materialization, installed-extension resolution, signal handling, staged\npre-spawn, and the forever wait. A host composes that lifecycle itself around `Manager`:\n\n```ts\nimport { Manager } from \"@cotal-ai/manager\";\nconst mgr = new Manager({ space, servers: brokerUrl, workspaceRoot });\nawait mgr.start(); // then wire your own SIGINT/SIGTERM -> mgr.stop()\n```\n\nUnlike delivery, the manager is **not** a pre-minted-scoped-cred daemon (auth-service is also a\nsigner: it holds fewer artifacts than the full trust bundle, but its data-account signing seed still\ngrants complete data-account mint authority on compromise, so this is not least-privilege). On `start()`\nthe manager reads its space's full trust chain **through its `secretStore`** (`getSpaceAuth(this.secrets,\nthis.space)`, composed from `auth/broker.json` + `auth/account.<key>.json`; a container may instead\nmount a stripped signer bundle at the legacy `auth/auth.json` key) and **self-mints** its supervisor cred and renewals from the\ndata-account signing seed. In static mode it also mints every per-agent cred from that seed; in user\nmode agents instead receive callout-minted bearers, but the manager still holds the signing seed for\nits own creds and renewal. So a hosted supervisor is a **trusted per-tenant account-signer process**,\nnot a least-privilege connect client. It additionally requires a `~/.cotal/meshes/space.<key>.json`\nregistry record and the workspace user-auth marker to start in user mode. `ManagerOptions.secretStore`\ninjects the one `SecretStore` the manager uses for **the signer itself (the split trust\nrecords)**, daemon-credential renewal (`remintDaemonCreds`), and per-agent secrets,\ndefaulting to the workspace filesystem store; pass the delivery daemon the *same* store for end-to-end\nhosted renewal. The store declares the same identity on both processes, or both set\n`COTAL_SECRET_STORE` to the same coordinate. The manager\nremints no daemon credential when the daemon names a different store, including a daemon that binds\nafter start; it keeps running and serving its own agents, so one space can carry a manager on more\nthan one workspace root. Pointing several managers at one coordinate is safe: the store identity\nalone cannot pick an owner (it carries no holder and no tiebreak, so every manager sharing the store\nmatches), so the manager that also holds the space's renewal lease is the one that remints and the\nrest skip it. Without that lease two owners would remint on independent timers with no ordering\nbetween them, and one write would land between the other's re-sign and its fingerprint-only\n`reloadCreds`. `cotal doctor auth --fix` takes the same lease before it re-signs, so a live manager\nand a local repair never race each other either. The signer IS now injectable: a hosted composition injects a KMS/Vault store and no\nsigning seed lands on the hosted disk. What remains is signer **isolation**. The seed is decrypted\nin-process at the manager's uid. That issue needs an OS sandbox or remote signer; it is no longer a\ncustody problem. The other knobs are `workspaceRoot` and the process-global `COTAL_HOME`.\n\n> Scope note: the **static-auth** operator paths (`cotal spawn`/`join`/`status`/`web`, via\n> `mesh-target` \u2192 `connect`/`preflight`) still read the signer from the local split records (sync\n> `loadSpaceAuth`). That is the single-machine composition, where the signer is on local disk by the\n> static-auth model; multi-tenant hosting runs **user mode**, which never mints from on-disk trust. The\n> store-injectable signer path is the hosted-server set: the manager, `remintDaemonCreds`, and delivery.\n\nThe typed remote-manager authority contract includes a one-shot terminal phase. A host implements\n`remoteAuthority.prepareAgentRetirement` to revoke the managed grant and finish its resumable\nrelease while preserving the UID, then `remoteAuthority.mintRetirementRequester` returns the\nhost-signed JWT for a fresh participant-owned nkey. The credential is pinned to the authenticated\nowner, server-derived manager serve principal, current instance epoch, and exact target lifecycle.\nThe manager then uses the existing auth `retireLifecycle` rail with the operation id derived by\n`managedRetirementOpId(target.lifecycleUid)`. This derivation is the reference remote-Manager\ncomposition's closed contract, not a rule for every retirement entry point; interactive retirement\nkeeps its existing operation identity and remains compatible. The `retireLifecycle` rail independently\nrecomputes the managed id from its broker-pinned target before any gate, head, intent, or barrier\naccess, so mint-time validation is not the terminal boundary. A failure keeps\nthe alias held. This does not expose the auth barrier or give the participant signer authority.\n\nIf the participant disappears after prepare, the host finishes the retirement itself on the auth\nservice's loopback face: `POST /managed-lifecycle/retire` (exported as `MANAGED_RETIRE_PATH` from\n`@cotal-ai/auth`) with the `Bearer <cap>` from `auth-service.json` and only\n`{ owner, actor, lifecycleUid }`. It has the interactive door's guards (POST only, no `Origin`, JSON,\ncapability, closed body) and is never served on the public face. The managed grant must already be\nrevoked at that uid, or it answers 409. It runs the same `managedRetirementOpId(uid)` operation as\nthe rail, and the rail and the door share one in-process flight, so a late participant request and\nthe host call converge on one barrier.\n\n| lifecycle head | answer |\n| --- | --- |\n| absent, or `retired` at another uid | `200 { retired: false, lifecycleUid, notStarted: true }` |\n| `active`/`retiring` at another uid | `409` |\n| `retired` at this uid | `200 { retired: true, lifecycleUid, alreadyRetired: true }` |\n| `active`/`retiring` at this uid | the barrier runs, then `200 { retired: true, lifecycleUid }` |\n\nDeprovisioning durables stays with `deprovisionAgent` and a `deprovisioner` credential. Pass `memberChannels` to both to also purge the retired lifecycle's durable membership rows on those concrete channels. The manager fills that list from the launch's concrete read channels and the delivery daemon's read-only `lifecycleMemberships` admin verb. When that verb cannot answer, rows on other channels stay retained, the teardown logs the inventory as incomplete, and retirement remains held pending retry rather than releasing the alias. Each teardown examines two exact consumers, one ACL key and the named member keys. KV deletion uses a native revision condition; a lost condition with a live replacement refuses rather than claiming absence. Consumer INFO checks before and after DELETE distinguish verified prior absence from disappearance. `acknowledged` counts native DELETE success replies. `disappeared` counts observed live-to-absent consumers and live KV rows whose conditional purge lost to a competing deletion. These KV rows were present at the first read, so they never count as prior `absent` or as this caller's `deleted`. The ACL and membership subtotals preserve that distinction. Neither establishes which concurrent caller uniquely removed a consumer, so `consumers.deleted` and the total `deleted` are `null` when a consumer disappears without a winner token. A repeat after verified absence reports zero, not an invented deletion. `refused` counts slots whose cleanup or state remains uncertain. A partial failure raises `DeprovisionError` carrying these bounded observations. The Manager's static sweep preserves unknown uniqueness rather than adding acknowledged requests as physical removals; its slot totals remain separate.\n\nA host that resumes retained managed actors also implements\n`remoteAuthority.validateRetainedAgent`. The participant sends back the actor token and sentinel it\nalready holds, plus the `nextRegistrationProof` returned by the activation response. That proof is\nhost-issued after registration and binds the manager owner, actor, lifecycle, identity nkeys, current\nregistration revision, and serving epoch. The host checks it against the current open manager gate,\nvalidates the retained secrets against its current managed row, and returns only the non-secret\nauthority shape. The manager binds every result coordinate and the returned authority back to its\ninventory before use. Do not copy the provider's `issuer.json` or `callout.json` into the participant\nstore. Both contain private signing or exchange authority.\n\nThe same composition supplies `remoteAuthority.agentBearerExchangeUrl`, the pinned public auth-service\nbase used by retained children. Remote adoption launches `agent-bearer --exchange-url <base>`; it must\nnot select the local `--dir` arm, which depends on a host-only auth-service process record.\n\nA host that lets a remote participant spawn FRESH managed agents implements\n`remoteAuthority.enrollManagedAgent`. The participant generates the standing actor token, writes it\nat mode 0600, and passes only its SHA-256 digest with the requested actor, label, role,\ncapabilities, and channel lists, so the plaintext secret never leaves the participant machine. There\nis deliberately no `lifecycleUid` input: the host selects the UID, because only the host sees the\nretirement tombstones that make a UID permanently unusable, and a participant-chosen UID could aim a\nfresh grant at a dead incarnation. The host authors the ledger grant, pre-creates the lifecycle-keyed\ndurables, clamps the requested lists to what the spawning owner already holds, and returns the owner,\nactor, chosen `lifecycleUid`, the space sentinel credentials, the effective lists, and\n`agentBearerExchangeUrl`. The manager binds every returned coordinate, re-keys the secret family onto\nthe returned UID, and launches `agent-bearer --exchange-url <base>`. When the hook is absent a\nsignerless manager refuses the user-mode spawn rather than authoring a local grant the host knows\nnothing about.\n\nBoth managed-agent operations ride the one verified `POST /manager-service-authority` transport as\n`kind: \"manager-managed-agent-enrollment\"` and `kind: \"manager-managed-agent-prepare-retirement\"`.\nStock `dispatchManagerAuthorityRequest` refuses both with `unimplemented`: they mutate host storage,\nand stock holds none of that composition. A host terminates its own public route, authenticates the\nhuman there, and asks the auth service for the decision at\n`POST /manager-service-authority/verify-enrollment` (exported as `VERIFY_ENROLLMENT_PATH` from\n`@cotal-ai/auth`) with the `Bearer <cap>` from `auth-service.json` and only `{ owner, request }`. That\ndoor has the managed retirement door's guards, derives the caller's scope from the local ledger rather\nthan the body, checks the manager gate and registration proof in-process, and answers\n`{ authorized: true, owner, actor, instanceId, serveEpoch }`. `authorizeRemoteManagedAgentEnrollment`\nand `authorizeRemoteManagedAgentPrepareRetirement` are exported too, for a host that composes the\ndecision without the HTTP hop. Both require ledger scope `supervise`; `spawn` and `admin` do not\nimply it.\n\nA host that runs managed agents on its own hosted runtime adds two more kinds on the same transport.\n`kind: \"manager-managed-agent-runtime-create\"` asks the host to create the runtime for one agent it\nalready enrolled, and `kind: \"manager-managed-agent-runtime-status\"` reads that runtime's state. Both\ncarry the manager envelope plus `target: { owner, actor, lifecycleUid }`, the coordinate the\nenrollment returned. Both schemas are closed. An unknown top-level or target field, including\n`providerRef`, `handle`, or `name`, is refused as `bad-request`, because the host alone issues and\nholds provider references. There is no stop, adopt, or probe kind: stop goes through\nprepare-retirement. Stock dispatch refuses both kinds with `unimplemented`, and the verify-enrollment\ndoor decides them. `authorizeRemoteManagedAgentRuntimeCreate` and\n`authorizeRemoteManagedAgentRuntimeStatus` apply the enrollment door's checks: host space, a target\nowner equal to the authenticated owner, the manager actor's own ledger row with `supervise`, the open\ngate, the current serve epoch, and the registration proof. Each returns only\n`{ owner, instanceId, actor, target }`. The door touches no provider and writes nothing. The host\nmatches the decision to its own intent record and performs the create afterwards. The host answers\nwith `state` (`reserved`, `creating`, `bound`, `create-unknown`, `closing`, or `closed`), `readiness`\n(`ready`, `bound-not-ready`, or `none`), and an optional `retirementPhase`. A manager builds requests\nwith `remoteManagerClient.remoteManagedAgentRuntimeRequest` and binds the answer with\n`remoteManagedAgentRuntimeState`.\n\nAn enrollment result may also carry `runtimeIntent: { state: \"reserved\" }` when the host reserved a\nhosted runtime for the agent. It is display-only. Older hosts omit it, the manager binds both shapes\nto the same material, and nothing reads it as authority.\n\nRemote user-mode managers must also supply `remoteAuthority.authorizeAdmin`. The manager builds each\nrequest only from the caller tuple parsed from the broker-authenticated endpoint subject, then relays\nthat tuple over the current registered manager lifecycle. HTTPS does not separately authenticate the\nrelayed caller. The host authenticates the manager operator, binds the request to the current open\nmanager gate, registration proof, serving epoch, and identity nkeys, then reads the caller's unified\nauthoritative row fresh. It returns only the manager owner and `authorized: boolean`, with every request\ncoordinate echoed. Missing, revoked, narrowed, foreign-owner, and stale-lifecycle callers all return\n`false`; malformed coordinates or corrupt and unavailable authority state fail the operation. The\nparticipant never reads or mirrors the host ledger, and the remote branch has no local fallback. The\nsame callback gates all `manager.admin` handlers, any-mode cross-owner control, and `ps` or `inspect`\ncross-owner visibility. Launch keeps its owner-equality policy.\n\nThe remote authority's instance executor remains the scoped maintenance credential for clean service\nderegistration and exact instance registration operations. It carries no records-stream consumer\nlifecycle authority. The manager's boot `goalidx` sweep uses the authenticated host operation, which\nreturns parsed `goalidx.manager.<owner>.>` entries for that owner only. The host keeps the sealed\nconsumer connection and its create/delete rights. The five-minute executor already renews through\n`remoteAuthority.renewExecutor`. The signerless supervisor, serve, goal-writer, session-ledger and\nper-run driver credentials do not yet have a complete remote renewal and adoption path. The\n[hosted runtime contract](https://github.com/Cotal-AI/Cotal/blob/main/docs/design/hosted-runtime-contracts.md) records the bounded additions and\ntheir ownership; it is not a shipped pooled service.\n\n**Signer isolation needs an OS sandbox.** The default pty runtime\nruns agent children under the *same* OS uid and the *same* `workspaceRoot`, so mode-0600 on\nthe trust records does not stop a hostile same-uid agent from reading their absolute paths. The reference\n[deploy](deploy.md) tree does not solve this: it mounts the signer into the agent's own container, so\nits phase-1 boundary isolates agents from each other, not the signer from the agent. A hosted\ncomposition must run the manager/minter that holds the signer in a different uid, container, or mount\nnamespace from the agent children, which mount no signer at all; that split is future\nhosted-composition work, so until it (or a remote/injected minter) exists, do not run untrusted\nagents under this manager.\n\n## Provisioning a space (one-space reference shape)\n\n```ts\nimport { createSpaceAuth, setupSpaceStreams, ensureDefaultDeliveryClass, mintCreds, newIdentity } from \"@cotal-ai/core\";\nconst auth = await createSpaceAuth(space); // trust bundle (in-memory seeds)\nconst provisionerCreds = await mintCreds(auth, newIdentity(), \"provisioner\");\nawait setupSpaceStreams({ servers: brokerUrl, space, creds: provisionerCreds });\n// SPEC section 4: write the default delivery class at space creation so it is wire-discoverable,\n// never inferred from the resolution fallback. A daemon-backed space is \"durable\".\nawait ensureDefaultDeliveryClass({ servers: brokerUrl, space, creds: provisionerCreds, deliveryClass: \"durable\" });\nconst deliveryCreds = await mintCreds(auth, newIdentity(), \"delivery\");\n// put deliveryCreds into your SecretStore under deliveryCredsKey(space, { injected: true })\n// (@cotal-ai/workspace) before booting delivery \u2014 the key is per-space, not the bare kind.\n```\n\nRendering the broker config for a user-auth space is `serverConfig(broker, spaces, { storeDir,\nmaxFileStore?, extraAccounts })`, where `extraAccounts` must include the callout account from\n`createCalloutAuth` so the auth-service has a broker account to answer on. That account never shares\nthe data account. `maxFileStore` is an optional positive integer byte cap; any other value throws.\n\nBroker trust and space accounts are separate authorities: `createBrokerAuth` mints the one\noperator + system account a broker trusts, `createSpaceAccountAuth(broker, space)` signs each\ntenant's data account under it, and `serverConfig(broker, spaces, opts)` renders them all into one\nconfig. A host composition can therefore provision several spaces on one broker today. `cotal up`\nrenders that config from every tenant the root's auth directory holds, so booting one space keeps\nthe broker trusting its siblings, and it refuses to render at all while any account record is\nunreadable. The rest of the CLI lifecycle is still broker-wide: `down`, `clean` and `backup` refuse\non a multi-space root rather than scoping to one tenant, and the per-space lifecycle is the\nremaining multi-space operator layer. See\n[Known gaps](#hosted-composition-gaps).\n\n## Hazardous provisioning primitives\n\n`mintCreds`, the full `Profile`/`CredentialKind` matrix, `createSpaceAuth`, and `stripSpaceAuth` are\nlow-level operator primitives. Handle them as account-authority material:\n\n- A holder of a `SpaceAuth` (or a `stripSpaceAuth` bundle, which **keeps** the data signing seed) is\n a fully-trusted tenant-account authority: it can mint `admin`, `provisioner`, and destructive\n profiles, not merely `supervisor`, and mint a DM-reading identity. `createSpaceAuth`'s full result\n holds operator, system, and account seeds in memory.\n- Choose `profile` and `MintOpts` from **server-side constants**, never from tenant input. `MintOpts`\n can widen the bounded TTL defaults; cap it at your boundary. `CREDENTIAL_LIFETIMES` is a policy\n record, not an authorization boundary.\n- Never log signer material or export it into env. Do not co-locate signer access with an untrusted\n connector/runtime process at the same OS uid (file permissions do not contain a same-uid reader;\n see the manager's isolation note). Segregate per tenant; rotate on compromise\n (`rotateDataAccountSigningKey`).\n\n## Hosted composition gaps\n\nThe primitives above are present as exports, but three capabilities are **not** cleanly composable\nfrom the public contract today. Each is tied to work in flight; a host either waits for the seam or\nscopes the capability out. None is a wire concern.\n\n1. **Delivery immediate live eviction and a fully-hosted membership feed.** The renewable\n `membership-rw.creds` is now a `SecretStore` kind. `startMembership` reads it through the\n injected store, and the manager re-signs it there. The graph-feed writer therefore renews on a hosted\n backend (its data connection adopts each generation on a preflight-proven 75% timer). What still\n reads from a fixed on-disk path are the *static* `membership-observer.creds` and\n `connection-evictor.creds` ($SYS creds, minted at the `up` that provisions the account and renewed by `up --rotate-sys`) and `membership.json`\n (`{accountId}`, non-secret config); those, plus the private provisioning wrapper, keep immediate\n live eviction and a fully-hosted feed a partial gap. Missing files degrade membership to\n traffic-only and make live eviction refuse (loudly). The supported delivery contract here is the\n Plane-3 durable backstop.\n2. **Supervisor signer isolation.** `ManagerOptions.secretStore` now injects the one `SecretStore` the\n manager reads/writes every secret through, including the composed `SpaceAuth`\n signer (the split trust records), its daemon-cred renewal, and its per-agent kinds. What remains is process\n isolation: the manager still decrypts the signer in-process at its uid, so untrusted agent children\n must run under a different uid/container/mount namespace or behind a future remote signer.\n3. **Per-space lifecycle on a shared broker.** The trust layer is multi-space\n (`createBrokerAuth` + `createSpaceAccountAuth` + N-space `serverConfig`, persisted as\n `broker.json` + `account.<key>.json`) and `cotal up` renders the whole tenant list, but there is\n no per-space provisioning verb and no per-space teardown/backup/restore: the CLI's broker-wide\n lifecycle verbs refuse on a multi-space root, naming the tenants.\n This is the remaining multi-space operator layer.\n4. **A non-Better-Auth production IdP.** The exchange core (`createIdpBridge`) is EdDSA-generic, but\n the stock provider and login client are Better-Auth-endpoint-shaped, `cotalAuthProvider`\n self-registers on import (colliding with a host-owned provider under `resolveAuthProvider`), and\n the login flow speaks Better Auth's device-code endpoints. A different IdP is a host-built auth\n composition on the low-level primitives, not a configuration change (see\n [the IdP callout contract](identity-and-auth.md#the-idp-callout-contract)).\n\n## Hosted durability\n\nSpace-durable **coordination** state (chat/DM/task history, live presence, membership runtime, the\ndurable ACL registry, leases) lives in **JetStream**, written by the delivery daemon and the\nendpoints. It is broker-resident and needs no host-side durable path.\n\nWhat is **not** in JetStream, and is hosting-critical, is trust and authorization state a host must\nplace and keep:\n\n| state | class | where today | hosted injection |\n|---|---|---|---|\n| full `SpaceAuth` trust chain (`auth/broker.json` + `auth/account.<key>.json`, composed; a stripped signer bundle may instead be mounted at the legacy `auth/auth.json` key) | signing authority | `SecretStore` | `SecretStore` (manager + renewal) |\n| auth kinds: callout account/creds/xkey, issuer private keys, owner-derivation secret, data-signer projection | signing/identity authority | four `SecretStore` kinds | `SecretStore` (auth-service) |\n| `delivery.creds` | standing scoped cred | `SecretStore` or `--creds` | `SecretStore` (delivery) |\n| actor ledger, IdP pin | authorization + trust config | ambient `userAuthStateDir(findCotalRoot(), space)` | none (root-relative; not `store`/`COTAL_HOME`) |\n| `membership-rw.creds` | standing scoped cred | `SecretStore` | `SecretStore` (delivery + manager renewal) |\n| membership-observer / connection-evictor creds + `membership.json` | scoped $SYS creds / config | workspace filesystem | none (see gap 1) |\n| manager agent creds, actor tokens, sentinel creds | lifecycle authority | `SecretStore` | `SecretStore` (manager `secretStore`) |\n| `~/.cotal/meshes/space.<key>.json` record (holds IdP trust pins/root pointers) | non-secret, integrity-critical | machine home | process-global `COTAL_HOME` only |\n| auth-health, renewal records | non-secret diagnostics | workspace filesystem | `workspaceRoot` |\n\nThe `SpaceAuth` trust chain and the auth-service store kinds are **separate** identities/projections,\nnever parts of one document. `auth-service.json` (the live exchange capability) is ephemeral runtime\nstate, not durable, but is sensitive while the daemon runs. `@cotal-ai/workspace` is machine-local\noperator tooling by design; personas, PID files, and the `current-mesh` pointer are truly local and\nmust **not** sit on a hosted durable path. Everything classed above as an authority is what a hosted\ncomposition must provision and persist: signer-bearing server secrets now have `SecretStore` seams;\nthe remaining non-injectable rows are the explicit ambient `workspaceRoot`/cwd paths above.\n\n## See also\n\n- [Substrate stability](stability.md): what v0.3 and the 0.x packages guarantee, and the projected v0.4 break.\n- [Identity and auth](identity-and-auth.md): the profile matrix, the signer, and the IdP callout contract.\n- [Delivery daemon](delivery-daemon.md): the Plane-3 durable backstop.\n- [Deploy](deploy.md): the reference container against an external broker.\n"
19638
+ "body": "# Embedding Cotal\n\n> **Guide** (informative) \xB7 **For:** implementers building a service on top of Cotal \xB7 **Prereqs:** [Architecture](architecture.md), [Identity and auth](identity-and-auth.md), [Delivery daemon](delivery-daemon.md)\n\nThe `cotal` binary in this repo is one composition root: an operator CLI. A separate service\n(for example a hosted, multi-tenant Cotal) does not fork this repo. It writes its **own**\ncomposition root that depends on the published `@cotal-ai/*` packages and imports the surfaces it\nwants. `bin/cotal.ts` uses the same composition pattern. This page is the contract for that: what is a real library\nexport you can build against, how to boot the server-side daemons from those exports, and where the\ncurrent export surface stops short of a fully hosted composition.\n\nThis is the \"guarded substrate\" boundary in practice. Nothing here reveals or assumes a specific\nhost; it documents the public seams any embedder composes.\n\n## What you embed\n\nThe supported reference shape here is **one broker operator serving one space** (one tenant: a\ndedicated data account, under an operator that also holds the system account and a quarantined\nauth-callout account) plus three standalone processes. The trust layer itself composes many spaces\nunder one broker operator today (`createBrokerAuth` + `createSpaceAccountAuth` + N-space\n`serverConfig`); what does not exist yet is the per-space **lifecycle** on a shared broker (see\n[Known gaps](#hosted-composition-gaps)). The three processes:\n\n| daemon | package | what it is |\n|---|---|---|\n| auth-service | `@cotal-ai/auth` | the NATS auth callout, the IdP token exchange, and JWKS. Plane 1 to Plane 2. |\n| delivery | `@cotal-ai/delivery` | the Plane-3 durable backstop: fan-out writer plus trusted reader, per space. |\n| supervise | `@cotal-ai/manager` | the per-machine agent lifecycle (spawn/despawn/attach), per space. |\n\n`mint`, `deliver`, and `auth-service` expose their behavior as direct library primitives, and the\nsupported one-space bootstrap below re-composes from exported low-level primitives. `supervise` and\nthe full `up` orchestration are **not** public runners: `up` also does broker bring-up, restore,\nprocess and registry management, and lifecycle work, and `supervise`'s orchestration is private (see\n[Supervisor signing authority](#supervisor-signing-authority)).\n\n## The export surface\n\nEverything below is a real export of a published package, reachable from the package root (each\npackage publishes only `.` via `dist/index.{js,d.ts}` and ships `files: [\"dist\"]`). Type-only names\nare marked; import them with `import type`.\n\n**Daemon runners and lifecycle**\n\n| symbol | package | purpose |\n|---|---|---|\n| `runAuthService(args, store?)` | `@cotal-ai/auth` | boot the auth-service daemon; `store` injects the secret material. |\n| `runDelivery(args, store?)` | `@cotal-ai/delivery` | boot the delivery daemon; `store` injects the scoped `delivery` cred. |\n| `startAuthService(inputs)` | `@cotal-ai/auth` | start one account-scoped auth-service context and return an `AuthServiceHandle` with the loopback `url`, the per-start `cap`, `readiness`, `drain`, and idempotent `close`. With the optional `publicFace` input it also serves the public exchange face and carries `publicUrl`. With the optional `platformControl` input the handle also has `platformControlAuthority`, the in-process platform control door. `runAuthService` remains the CLI entry. |\n| `PlatformControlAuthorityRequest`, `PlatformControlInnerRequest`, `PlatformControlAuthorityResult`, `PlatformControlAssignment` *(types)* | `@cotal-ai/core` | the closed envelope, its inner request union, its result and the backend's assignment row for `platformControlAuthority`. `platformControlOwner` in `@cotal-ai/auth` derives the `p_` owner the door issues under. |\n| `startDeliveryService(inputs)` | `@cotal-ai/delivery` | start one account-scoped delivery instance and return a `HostedServiceHandle` with `readiness`, `drain`, and idempotent `close`. The process runner remains the CLI entry. |\n| `deliveryCredsKey(space, composition)`, `membershipRwCredsKey(space, composition)` | `@cotal-ai/workspace` | build the secret-store keys the delivery cred and the membership feed's rw cred are read/re-signed under. Keys are **per-space**: `space.<hex>/<kind>`. A hosted composition passes `{ injected: true }`. |\n| `retireManagerInstanceIdentity(root, space, expected)` | `@cotal-ai/workspace` | remove a persisted manager identity only if its complete instance id and serve identity still match `expected`. Returns `removed` or `absent`; refuses malformed, nonregular, and changed records. `absent` is not proof of ownership or successful teardown. The caller must separately prove stop and retirement ownership before using it. |\n| `DELIVERY_CREDS_KIND`, `MEMBERSHIP_RW_CREDS_KIND` | `@cotal-ai/workspace` | the operator-facing KIND names (`delivery.creds`, `membership-rw.creds`) those keys are built from, and what renewal results report. A kind is **not** a key: putting a cred under the bare kind writes the pre-0.4 flat location, which nothing reads. |\n| `Manager`, `ManagerOptions` *(type)* | `@cotal-ai/manager` | construct and run a supervisor in-process; `ManagerOptions.secretStore` injects the one store it reads/writes every secret through. `ManagerOptions.remoteAuthority` is the hosted manager-service authority bundle, including host-owned release, retained-validation, goal-index, and serve-time admin-authorization callbacks. |\n| `ManagerOptions.pooled` | `@cotal-ai/manager` | require signerless remote authority and an explicit non-custodial runtime before local execution starts. A pooled composition must supply the assigned account key and all-duty renewal callback; the CLI's default remains unchanged. A signed-in human's manager gets that material from `managerServiceAuthority`. A platform-run control manager gets it from `AuthServiceHandle.platformControlAuthority` with the shipped `remoteManagerClient` builders, as the [platform control authority](https://github.com/Cotal-AI/Cotal/blob/main/docs/design/platform-pooled-control-authority.md) design describes. |\n| `createRuntime`, `Runtime` *(type)* | `@cotal-ai/manager` | resolve the spawn backend (pty built in). |\n| `liveKvEntries(kv, filterOrOptions?, options?)`, `LiveKvEntriesOptions` *(type)* | `@cotal-ai/core` | read live KV entries in one finite scan. Pass `{ signal }` as the second argument or after a key filter to cancel. An interrupted scan throws `IncompleteKvScan`; cancellation throws the signal reason, including during an empty-bucket bind. The scan deletes only its owned consumer; broker inactivity expiry remains the crash or deletion-failure backstop. |\n\nThe remote manager authority parser accepts `renewStandingBundle` and `renewRunDriver` only with\nan assigned account nkey, the current manager process epoch, and a host-authenticated registration\nproof. A run renewal also names its active holder, takeover, epoch, fencing token, and the two\nexisting nkeys. The host must fresh-check those coordinates against its registration gate and run\njournal before issuing server-selected profiles. A host without that renewal authorization refuses\nthe request. Until the host issuer wires the operations and validates them on real connections,\nthe presence of these types is not an operational pooled renewal guarantee.\n\nWith `renewStandingBundle` configured, the manager renews all five standing credentials together.\nIt checks every returned credential for the held nkey and assigned account, test-connects each one,\nand adopts them only if the serve epoch has not moved. A refused or failed candidate leaves the\ncurrent credentials in place and records the refusal as cleanup debt until a later renewal succeeds.\nOn shutdown, after the standing context is drained, an expired maintenance executor is renewed\nthrough the existing scoped host operation so deregistration can finish without restarting duties.\n\n**Provisioning and minting** (all `@cotal-ai/core`)\n\n| symbol | purpose |\n|---|---|\n| `createBrokerAuth(label)` | mint BROKER trust: the operator and system account one nats-server trusts. One per broker, shared by every space on it. |\n| `createSpaceAccountAuth(broker, space)` | mint one space's own data account, signed by that broker's operator: the add-a-tenant primitive. |\n| `createSpaceAuth(space)` | the one-space convenience: broker trust + one account in a single composed bundle. |\n| `setupSpaceStreams({ servers, space, creds })` | create the space's JetStream streams. |\n| `ensureDefaultDeliveryClass({ servers, space, creds?, deliveryClass })` | write the space's default delivery class at creation so it is wire-discoverable (SPEC section 4). |\n| `serverConfig(broker, spaces, { storeDir, maxFileStore?, extraAccounts?, port?, host? })` | render the broker config: one operator, N space accounts. `storeDir` is required, `maxFileStore` caps JetStream file storage in bytes (omitted, nats-server's dynamic default applies), and `extraAccounts` preloads the auth-callout account. |\n| `mintCreds(auth, identity, profile, opts?)` | mint a scoped cred for any `Profile`. |\n| `mintMembershipObserverCreds`, `mintConnectionEvictorCreds` | mint the membership/eviction scoped creds. |\n| `provisionAgent`, `provisionAgentDurables` | create a principal's bind-only durables. |\n| `newIdentity`, `stripSpaceAuth` | a fresh nkey identity; a stripped signer bundle (data signing seed only). |\n| `Profile`, `CredentialKind`, `MintOpts`, `SpaceAuth` *(types)*, `CREDENTIAL_LIFETIMES` | the profile matrix and cred lifetime policy. |\n\n**Auth building blocks** (all `@cotal-ai/auth`)\n\n| symbol | purpose |\n|---|---|\n| `createCalloutAuth`, `startAuthCallout` | the NATS auth-callout responder. |\n| `createUserTokenIssuer`, `pinnedJwksResolver` | mint and verify the Cotal user bearer. |\n| `createIdpBridge` | exchange a verified IdP JWT for a Cotal bearer (see [the callout contract](identity-and-auth.md#the-idp-callout-contract)). |\n| `deriveOwnerToken`, `validateUserToken` | owner derivation; strict bearer validation. |\n| `cotalAuthProvider` | the self-registering `auth-provider` extension. |\n| `ensureCalloutAuth`/`loadCalloutAuth`, `ensureIssuer`/`loadIssuer`, `ensureOwnerSecret`/`loadOwnerSecret` | read/write the auth secret kinds through a `SecretStore`. |\n\n**Seams and the wire** (all `@cotal-ai/core` unless noted)\n\n| symbol | purpose |\n|---|---|\n| `SecretStore` *(type)* | the durable hosted-secret seam (get/put/delete); `get()` returns raw seeds/keys into process memory, so it is a blob seam, not HSM/KMS signing. |\n| `FsSecretStore`, `workspaceSecretStore(root)` | the filesystem default. **These live in `@cotal-ai/workspace`, not core.** |\n| `AuthProvider` *(type)*, `Connector` *(type)*, `Runtime` *(type)*, `Command` *(type)* | the extension contracts; implementations self-register on import. |\n| `registry` | the shared registry a composition root pulls surfaces into. |\n| `CotalEndpoint`, subjects, message types | the wire client and shapes. |\n| `ParsedArgs` *(type)* | the shape the daemon runners take (see below). |\n\nFor a Linux Unix-socket adapter, `peerCredentials(socket)` from `@cotal-ai/seat` returns\nkernel-observed peer `pid`, `uid` and `gid`. Compare these against the host's authorization\npolicy; request-supplied identity and process liveness do not replace that policy or a\nlifecycle fence. The helper starts no custodian and refuses unsupported platforms or a\nmissing native helper.\n\nThe runners take a CLI-shaped `ParsedArgs`, not a typed options object, so a host fabricates one:\n\n```ts\nconst args: ParsedArgs = { values: { space, server, port: \"0\" }, positionals: [], raw: [] };\n```\n\nFor an embedded delivery instance, use `startDeliveryService` instead. Its `HostedContextInputs`\ninclude the account public key and lifecycle UID, space, broker URL, injected store, stable\n`storeIdentity`, and an explicit `stateDir`. The store must declare that same injected identity.\nThe initial delivery credential must belong to the assigned account. The function returns only\nafter the delivery responder is bound. `close()` withdraws serving and releases only the lease\nowned by that instance. It closes both membership connections even when a disconnected drain\nfails, so they cannot reconnect after closure. Credential-expiry health state clears after\nsuccessful broker-verified adoption through the existing `reloadCreds` rail. A failed start\nrefuses locally without exiting the host process or stopping another account's delivery service.\nIf a health fault occurs during an asynchronous store read, startup rejects when the read returns\nand closes any resources created by that late completion.\n\n`startAuthService` takes the same `HostedContextInputs`. The store must declare the assigned\ninjected identity, and its data account must be the assigned account. The IdP pin and ledger live\nunder the explicit `stateDir`. The context never resolves a workspace root from the working\ndirectory and has no local manager, so only remote manager gates can be selected. It returns after\nthe authority plane, the callout subscription and the loopback listener are bound. A fenced plane or\na lost broker connection makes that context `unavailable` and closes it without exiting the process.\nThe host writes no discovery file for it. The handle carries what `auth-service.json` holds for a\nCLI start: the loopback `url`, `publicUrl` when a public face runs, and the per-start `cap`. The cap\nalone authorizes the loopback host actions, lifecycle retirement and managed-agent enrollment\nverification, so keep it in the authority process. `publicFace` takes the CLI's public face inputs\n(`port`, `url`, `trustedProxy`, `advertisedServer`, `agentProvisioningUrl`) under the same rules,\nand a face without a port refuses to start.\n\nTwo optional inputs serve a platform composition. `platformControl: { observeAssignment }` adds\n`platformControlAuthority` to the handle. It is a typed in-process method, served on no listener,\nthat issues the manager-service request family for the one control manager the backend assigned to\nthis account, under a derived `p_` owner. It reads the assignment fresh on every call and refuses\nan IdP token, another account, a stale revision, another instance or lifecycle, and an instance\nanother owner registered. It refuses `prepare` and `activate` while the assignment's named\npredecessor is still registered or frozen. Without the input the member is `undefined`.\n`standingRenewableTtlSeconds` is forwarded unchanged to the authority plane, which bounds it to 5\nto 86400 seconds. It is a trusted-host input for the renewal rehearsal, and no request or CLI flag\nsets it. SPEC \xA713.1 and \xA713.6 define the view.\n\nThe auth plane can renew a registered manager's\nfive standing credentials from the current service registration. Run-driver renewal still\nrefuses without an authoritative activated-run reader, so these handles do not yet make a\ncomplete pooled auth and delivery host. A fresh auth plane can initialize without a\ndelivery-admin responder. Reclaiming a held claim from a dead predecessor needs the delivery\ninstance first: its admin rail must complete the broker connection-liveness sweep before the\nauth plane takes the claim. An absent or inconclusive oracle refuses the reclaim.\n\n### Remote manager client composition\n\n`@cotal-ai/manager` exports the `remoteManagerClient` namespace, containing the stock remote\nrequest builders and response validators, and `registerRemoteManagerAuthority` for registration\nwith a host-issued prepare credential. `RemoteManagerIdentityState` describes the five private\nmanager identities stored under an explicit account-local root. Use these public exports when\ncomposing `ManagerOptions.remoteAuthority`; do not copy CLI validators or import private modules.\n`managerClusterArtifacts()` returns the canonical document, manifest and their digests used by\nregistration. Supply its `[document, manifest]` pair when constructing the activation request\nwith `remoteManagerClient.remoteManagerAuthorityRequest` and the stock registration proof.\nThe host still validates the artifact closure and current registration before activation.\nThe namespace includes closed standing/run renewal, admission, maintenance, enrollment and\nretirement helpers. It provides no signer or new grant. The host still owns authenticated issuance,\ncurrent registration and activated-run observations, and any guarded foreign-holder repair.\nAn embedding must preserve those checks and supply a supported runtime; the client exports alone\ndo not provide a pooled runtime, an authority service or a complete hosted context.\n\n### Long-lived endpoints take a bearer function\n\n`EndpointOptions.bearer` accepts either a string or a function, and the difference is not stylistic.\nA string is minted once, so when it expires (which it will: callout bearers live minutes) the\nendpoint has nothing to renew with. It will not present the dead token to the broker, since that is\na guaranteed denial that still costs a full auth-callout round trip. It refuses to reconnect, emits\n`warning` saying which case it is in, and retries on a widening backoff until the process\nre-authenticates and rebuilds it. Retry notices use `warning` rather than `error` because Node\nrethrows an unhandled `error` event and would kill a host the endpoint is still trying to recover.\n\nPass a **function** for anything that outlives one bearer. That is a renewal source: it is called\nahead of each expiry and again whenever a reconnect finds the cached bearer dead, and it requires\nexplicit `card.owner` and `card.actor`. The first-party surfaces already do this\n(`UserViewAuth.source`, the connector's `agentBearerCommand`). A string bearer is for a short\none-shot connection.\n\nLong-lived hosts must also subscribe to the endpoint's `warning` event. It carries conditions the\nendpoint is surviving, including failed credential renewal, reconnect retries, and a durable leave\nit keeps retrying after the broker refuses a durable channel's live subscription. A host may choose\nto ignore warnings for a one-shot endpoint whose awaited operation owns the verdict, but that choice\nshould be explicit. An unhandled warning is nonfatal and silent.\n\n## Booting the daemons\n\n### auth-service\n\n`runAuthService(args, store?)` reads its provisioned long-lived secret kinds (service keys, callout\naccount, issuer keys, owner secret) through the injected `SecretStore`; a host provisions those into\nthe store first. It is a **signer and identity authority**, not a scoped daemon: at runtime it holds\nthe data-account and callout-account signing seeds, the issuer's private JWKs, and the\nowner-derivation secret in process memory (`SecretStore.get` exports raw values). The IdP pin and the\nactor ledger are **not** store-injected: `runAuthService` resolves them under\n`userAuthStateDir(findCotalRoot(), space)`, a path relative to the process working directory, so a\nhost provisions those into that exact directory (neither `store` nor `COTAL_HOME` selects it). It\nalso writes an ephemeral `auth-service.json` discovery file there that carries the live exchange\ncapability. That file appears only after every plane is bound, so waiting on it is the readiness\nsignal: a host that also passes the daemon's pid to the provider's `ready()` gets a process-bound\nwait. The wait extends past the base timeout while that pid is alive, up to a fixed bound, and it\nends at once when the pid exits.\n\n```ts\nimport { runAuthService } from \"@cotal-ai/auth\";\n// store implements SecretStore over your secret backend; get() returns raw seeds into memory.\n// Provision the auth secret kinds into the store, AND the IdP pin + actor ledger under\n// userAuthStateDir(findCotalRoot(), space), before this call.\nawait runAuthService(\n { values: { space, server: brokerUrl, port: \"8081\" }, positionals: [], raw: [] },\n store,\n);\n```\n\n### delivery\n\n`runDelivery(args, store?)` runs from a **pre-minted scoped `delivery` cred** and never loads the\nsigner. Provide the cred either through the injected store (under\n`deliveryCredsKey(space, { injected: true })`) or with a\n`--creds` file; the two are mutually exclusive. The daemon re-fetches the cred from the store at 75%\nof its JWT lifetime and fails loud rather than riding to expiry, so **something must re-sign a fresh\ncred into that same store**. When that read finds the previous generation still there, the daemon\nreports the missed remint and retries in 60 seconds. The current cred stays live until its expiry,\nand the store is read once per retry rather than once per second.\n\n```ts\nimport { runDelivery } from \"@cotal-ai/delivery\";\nawait runDelivery({ values: { space, server: brokerUrl }, positionals: [], raw: [] }, store);\n```\n\nThat renewal is a **signer** operation, not the delivery daemon's:\n`remintDaemonCreds(root, space, store?, { preflight? })` (`@cotal-ai/workspace`) reads the `SpaceAuth`\nsigner **through the same resolved `store`** (`getSpaceAuth(store ?? workspaceSecretStore(root), space)`,\nkeys `auth/broker.json` + `auth/account.<key>.json`; the pre-split `auth/auth.json` monolith is\nmigration input and the container signer mount only) and re-signs the daemon creds (`delivery.creds` and the membership feed's\n`membership-rw.creds`) back into that store. The injected `store` is both the signer source and the\ncredential destination, never a split. `space` is **required** and validated against the store's signer, so a\nstore swapped to a different space cannot re-sign over the wrong broker's creds. `preflight` is a\ncaller-supplied proof that the broker accepts the credential. The reference `Manager` passes a\n`probeConnect` over its `servers`. It gates **every** candidate before overwriting the last-good,\nwhether the signer is a full bundle or a stripped projection: a bundle's JWT chain proves only that\nit is self-consistent and\nnamed the space, NOT that its account is the broker's *current* account for that space (two\n`createSpaceAuth(space)` calls yield same-named, different-account chains), so a same-label alternate\nsigner would otherwise mint a broker-dead cred and clobber the good one. The offline local repair (`doctor auth --fix`) has no preflight. It permits the overwrite only\nunder **authority continuity**: the candidate must be signed by the same account signing key (`iss`) as the current\n(already broker-accepted) cred. A same-label alternate account breaks continuity and is refused, full or\nstripped; a legitimate local re-sign is continuous and proceeds without a network. The reference\n`Manager` runs it on a schedule against its **own**\n`secretStore` (see below), so passing the manager and the delivery daemon the *same* store closes the\nrenewal loop end-to-end on an injected backend: the manager reads the signer from the store, re-signs\ninto it, and the daemon adopts each generation on a preflight-proven 75% timer. The stock\ncross-host composition cannot satisfy that by writing one filesystem and fingerprinting another:\n`Manager.start()` and every later remint challenge the daemon's `reloadStoreIdentity` and a\ndivergent pair is refused naming both stores. The identity is the store the daemon actually\nreloads: an injected coordinate, the workstation root only when `--creds` is\n`<root>/.cotal/<spaceSegment(space)>/delivery.creds` (matching the canonical arm), the\nfile's own directory for any other `--creds` path, or the workstation root. Uninjected\n`--creds` that names one real workstation while process cwd resolves another is refused\nat start, naming both, because membership-rw still uses `findCotalRoot`. A `--creds`\npath that is not under any `.cotal` tree is not that case and is not refused here. It never\nwalks ancestors with `findCotalRoot`. No bound daemon is not a named\nstore, so start proceeds; a later daemon on a foreign store is refused on the next remint.\nThe first-party filesystem adapter declares its workspace-root identity on the store itself. Other\ninjected adapters declare their stable coordinate on `SecretStore.identity`, or name it in\n`COTAL_SECRET_STORE` on both processes. It never throws: it\nreturns per-file results (`skipped: \"no-auth\"` when the store holds no signer records),\nso the caller must check them or the cred still rides to expiry. A composition whose signer lives in\nKMS/Vault simply injects that store; no bespoke renewal is needed. A `--creds` file path must be\nreplaced atomically before the 75% read. The signer can now be injected behind the store seam, which\nresolves custody. The remaining hosted gap is signer **isolation**. The seed is still decrypted\nin-process at the manager's uid, so it needs an OS sandbox or remote signer.\n\n### Supervisor signing authority\n\n`@cotal-ai/manager` exports the `Manager` class; there is **no** `runSupervise(opts)` runner. The\nprivate CLI `runManager` also does broker-reachability checks, space/default resolution,\nroster/launch parsing and materialization, installed-extension resolution, signal handling, staged\npre-spawn, and the forever wait. A host composes that lifecycle itself around `Manager`:\n\n```ts\nimport { Manager } from \"@cotal-ai/manager\";\nconst mgr = new Manager({ space, servers: brokerUrl, workspaceRoot });\nawait mgr.start(); // then wire your own SIGINT/SIGTERM -> mgr.stop()\n```\n\nUnlike delivery, the manager is **not** a pre-minted-scoped-cred daemon (auth-service is also a\nsigner: it holds fewer artifacts than the full trust bundle, but its data-account signing seed still\ngrants complete data-account mint authority on compromise, so this is not least-privilege). On `start()`\nthe manager reads its space's full trust chain **through its `secretStore`** (`getSpaceAuth(this.secrets,\nthis.space)`, composed from `auth/broker.json` + `auth/account.<key>.json`; a container may instead\nmount a stripped signer bundle at the legacy `auth/auth.json` key) and **self-mints** its supervisor cred and renewals from the\ndata-account signing seed. In static mode it also mints every per-agent cred from that seed; in user\nmode agents instead receive callout-minted bearers, but the manager still holds the signing seed for\nits own creds and renewal. So a hosted supervisor is a **trusted per-tenant account-signer process**,\nnot a least-privilege connect client. It additionally requires a `~/.cotal/meshes/space.<key>.json`\nregistry record and the workspace user-auth marker to start in user mode. `ManagerOptions.secretStore`\ninjects the one `SecretStore` the manager uses for **the signer itself (the split trust\nrecords)**, daemon-credential renewal (`remintDaemonCreds`), and per-agent secrets,\ndefaulting to the workspace filesystem store; pass the delivery daemon the *same* store for end-to-end\nhosted renewal. The store declares the same identity on both processes, or both set\n`COTAL_SECRET_STORE` to the same coordinate. The manager\nremints no daemon credential when the daemon names a different store, including a daemon that binds\nafter start; it keeps running and serving its own agents, so one space can carry a manager on more\nthan one workspace root. Pointing several managers at one coordinate is safe: the store identity\nalone cannot pick an owner (it carries no holder and no tiebreak, so every manager sharing the store\nmatches), so the manager that also holds the space's renewal lease is the one that remints and the\nrest skip it. Without that lease two owners would remint on independent timers with no ordering\nbetween them, and one write would land between the other's re-sign and its fingerprint-only\n`reloadCreds`. `cotal doctor auth --fix` takes the same lease before it re-signs, so a live manager\nand a local repair never race each other either. The signer IS now injectable: a hosted composition injects a KMS/Vault store and no\nsigning seed lands on the hosted disk. What remains is signer **isolation**. The seed is decrypted\nin-process at the manager's uid. That issue needs an OS sandbox or remote signer; it is no longer a\ncustody problem. The other knobs are `workspaceRoot` and the process-global `COTAL_HOME`.\n\n> Scope note: the **static-auth** operator paths (`cotal spawn`/`join`/`status`/`web`, via\n> `mesh-target` \u2192 `connect`/`preflight`) still read the signer from the local split records (sync\n> `loadSpaceAuth`). That is the single-machine composition, where the signer is on local disk by the\n> static-auth model; multi-tenant hosting runs **user mode**, which never mints from on-disk trust. The\n> store-injectable signer path is the hosted-server set: the manager, `remintDaemonCreds`, and delivery.\n\nThe typed remote-manager authority contract includes a one-shot terminal phase. A host implements\n`remoteAuthority.prepareAgentRetirement` to revoke the managed grant and finish its resumable\nrelease while preserving the UID, then `remoteAuthority.mintRetirementRequester` returns the\nhost-signed JWT for a fresh participant-owned nkey. The credential is pinned to the authenticated\nowner, server-derived manager serve principal, current instance epoch, and exact target lifecycle.\nThe manager then uses the existing auth `retireLifecycle` rail with the operation id derived by\n`managedRetirementOpId(target.lifecycleUid)`. This derivation is the reference remote-Manager\ncomposition's closed contract, not a rule for every retirement entry point; interactive retirement\nkeeps its existing operation identity and remains compatible. The `retireLifecycle` rail independently\nrecomputes the managed id from its broker-pinned target before any gate, head, intent, or barrier\naccess, so mint-time validation is not the terminal boundary. A failure keeps\nthe alias held. This does not expose the auth barrier or give the participant signer authority.\n\nIf the participant disappears after prepare, the host finishes the retirement itself on the auth\nservice's loopback face: `POST /managed-lifecycle/retire` (exported as `MANAGED_RETIRE_PATH` from\n`@cotal-ai/auth`) with the `Bearer <cap>` from `auth-service.json` and only\n`{ owner, actor, lifecycleUid }`. It has the interactive door's guards (POST only, no `Origin`, JSON,\ncapability, closed body) and is never served on the public face. The managed grant must already be\nrevoked at that uid, or it answers 409. It runs the same `managedRetirementOpId(uid)` operation as\nthe rail, and the rail and the door share one in-process flight, so a late participant request and\nthe host call converge on one barrier.\n\n| lifecycle head | answer |\n| --- | --- |\n| absent, or `retired` at another uid | `200 { retired: false, lifecycleUid, notStarted: true }` |\n| `active`/`retiring` at another uid | `409` |\n| `retired` at this uid | `200 { retired: true, lifecycleUid, alreadyRetired: true }` |\n| `active`/`retiring` at this uid | the barrier runs, then `200 { retired: true, lifecycleUid }` |\n\nDeprovisioning durables stays with `deprovisionAgent` and a `deprovisioner` credential. Pass `memberChannels` to both to also purge the retired lifecycle's durable membership rows on those concrete channels. The manager fills that list from the launch's concrete read channels and the delivery daemon's read-only `lifecycleMemberships` admin verb. When that verb cannot answer, rows on other channels stay retained, the teardown logs the inventory as incomplete, and retirement remains held pending retry rather than releasing the alias. Each teardown examines two exact consumers, one ACL key and the named member keys. KV deletion uses a native revision condition; a lost condition with a live replacement refuses rather than claiming absence. Consumer INFO checks before and after DELETE distinguish verified prior absence from disappearance. `acknowledged` counts native DELETE success replies. `disappeared` counts observed live-to-absent consumers and live KV rows whose conditional purge lost to a competing deletion. These KV rows were present at the first read, so they never count as prior `absent` or as this caller's `deleted`. The ACL and membership subtotals preserve that distinction. Neither establishes which concurrent caller uniquely removed a consumer, so `consumers.deleted` and the total `deleted` are `null` when a consumer disappears without a winner token. A repeat after verified absence reports zero, not an invented deletion. `refused` counts slots whose cleanup or state remains uncertain. A partial failure raises `DeprovisionError` carrying these bounded observations. The Manager's static sweep preserves unknown uniqueness rather than adding acknowledged requests as physical removals; its slot totals remain separate.\n\nA host that resumes retained managed actors also implements\n`remoteAuthority.validateRetainedAgent`. The participant sends back the actor token and sentinel it\nalready holds, plus the `nextRegistrationProof` returned by the activation response. That proof is\nhost-issued after registration and binds the manager owner, actor, lifecycle, identity nkeys, current\nregistration revision, and serving epoch. The host checks it against the current open manager gate,\nvalidates the retained secrets against its current managed row, and returns only the non-secret\nauthority shape. The manager binds every result coordinate and the returned authority back to its\ninventory before use. Do not copy the provider's `issuer.json` or `callout.json` into the participant\nstore. Both contain private signing or exchange authority.\n\nThe same composition supplies `remoteAuthority.agentBearerExchangeUrl`, the pinned public auth-service\nbase used by retained children. Remote adoption launches `agent-bearer --exchange-url <base>`; it must\nnot select the local `--dir` arm, which depends on a host-only auth-service process record.\n\nA host that lets a remote participant spawn FRESH managed agents implements\n`remoteAuthority.enrollManagedAgent`. The participant generates the standing actor token, writes it\nat mode 0600, and passes only its SHA-256 digest with the requested actor, label, role,\ncapabilities, and channel lists, so the plaintext secret never leaves the participant machine. There\nis deliberately no `lifecycleUid` input: the host selects the UID, because only the host sees the\nretirement tombstones that make a UID permanently unusable, and a participant-chosen UID could aim a\nfresh grant at a dead incarnation. The host authors the ledger grant, pre-creates the lifecycle-keyed\ndurables, clamps the requested lists to what the spawning owner already holds, and returns the owner,\nactor, chosen `lifecycleUid`, the space sentinel credentials, the effective lists, and\n`agentBearerExchangeUrl`. The manager binds every returned coordinate, re-keys the secret family onto\nthe returned UID, and launches `agent-bearer --exchange-url <base>`. When the hook is absent a\nsignerless manager refuses the user-mode spawn rather than authoring a local grant the host knows\nnothing about.\n\nBoth managed-agent operations ride the one verified `POST /manager-service-authority` transport as\n`kind: \"manager-managed-agent-enrollment\"` and `kind: \"manager-managed-agent-prepare-retirement\"`.\nStock `dispatchManagerAuthorityRequest` refuses both with `unimplemented`: they mutate host storage,\nand stock holds none of that composition. A host terminates its own public route, authenticates the\nhuman there, and asks the auth service for the decision at\n`POST /manager-service-authority/verify-enrollment` (exported as `VERIFY_ENROLLMENT_PATH` from\n`@cotal-ai/auth`) with the `Bearer <cap>` from `auth-service.json` and only `{ owner, request }`. That\ndoor has the managed retirement door's guards, derives the caller's scope from the local ledger rather\nthan the body, checks the manager gate and registration proof in-process, and answers\n`{ authorized: true, owner, actor, instanceId, serveEpoch }`. `authorizeRemoteManagedAgentEnrollment`\nand `authorizeRemoteManagedAgentPrepareRetirement` are exported too, for a host that composes the\ndecision without the HTTP hop. Both require ledger scope `supervise`; `spawn` and `admin` do not\nimply it.\n\nA host that runs managed agents on its own hosted runtime adds two more kinds on the same transport.\n`kind: \"manager-managed-agent-runtime-create\"` asks the host to create the runtime for one agent it\nalready enrolled, and `kind: \"manager-managed-agent-runtime-status\"` reads that runtime's state. Both\ncarry the manager envelope plus `target: { owner, actor, lifecycleUid }`, the coordinate the\nenrollment returned. Both schemas are closed. An unknown top-level or target field, including\n`providerRef`, `handle`, or `name`, is refused as `bad-request`, because the host alone issues and\nholds provider references. There is no stop, adopt, or probe kind: stop goes through\nprepare-retirement. Stock dispatch refuses both kinds with `unimplemented`, and the verify-enrollment\ndoor decides them. `authorizeRemoteManagedAgentRuntimeCreate` and\n`authorizeRemoteManagedAgentRuntimeStatus` apply the enrollment door's checks: host space, a target\nowner equal to the authenticated owner, the manager actor's own ledger row with `supervise`, the open\ngate, the current serve epoch, and the registration proof. Each returns only\n`{ owner, instanceId, actor, target }`. The door touches no provider and writes nothing. The host\nmatches the decision to its own intent record and performs the create afterwards. The host answers\nwith `state` (`reserved`, `creating`, `bound`, `create-unknown`, `closing`, or `closed`), `readiness`\n(`ready`, `bound-not-ready`, or `none`), and an optional `retirementPhase`. A manager builds requests\nwith `remoteManagerClient.remoteManagedAgentRuntimeRequest` and binds the answer with\n`remoteManagedAgentRuntimeState`.\n\nAn enrollment result may also carry `runtimeIntent: { state: \"reserved\" }` when the host reserved a\nhosted runtime for the agent. It is display-only. Older hosts omit it, the manager binds both shapes\nto the same material, and nothing reads it as authority.\n\nRemote user-mode managers must also supply `remoteAuthority.authorizeAdmin`. The manager builds each\nrequest only from the caller tuple parsed from the broker-authenticated endpoint subject, then relays\nthat tuple over the current registered manager lifecycle. HTTPS does not separately authenticate the\nrelayed caller. The host authenticates the manager operator, binds the request to the current open\nmanager gate, registration proof, serving epoch, and identity nkeys, then reads the caller's unified\nauthoritative row fresh. It returns only the manager owner and `authorized: boolean`, with every request\ncoordinate echoed. Missing, revoked, narrowed, foreign-owner, and stale-lifecycle callers all return\n`false`; malformed coordinates or corrupt and unavailable authority state fail the operation. The\nparticipant never reads or mirrors the host ledger, and the remote branch has no local fallback. The\nsame callback gates all `manager.admin` handlers, any-mode cross-owner control, and `ps` or `inspect`\ncross-owner visibility. Launch keeps its owner-equality policy.\n\nThe remote authority's instance executor remains the scoped maintenance credential for clean service\nderegistration and exact instance registration operations. It carries no records-stream consumer\nlifecycle authority. The manager's boot `goalidx` sweep uses the authenticated host operation, which\nreturns parsed `goalidx.manager.<owner>.>` entries for that owner only. The host keeps the sealed\nconsumer connection and its create/delete rights. The five-minute executor already renews through\n`remoteAuthority.renewExecutor`. The signerless supervisor, serve, goal-writer, session-ledger and\nper-run driver credentials do not yet have a complete remote renewal and adoption path. The\n[hosted runtime contract](https://github.com/Cotal-AI/Cotal/blob/main/docs/design/hosted-runtime-contracts.md) records the bounded additions and\ntheir ownership; it is not a shipped pooled service.\n\n**Signer isolation needs an OS sandbox.** The default pty runtime\nruns agent children under the *same* OS uid and the *same* `workspaceRoot`, so mode-0600 on\nthe trust records does not stop a hostile same-uid agent from reading their absolute paths. The reference\n[deploy](deploy.md) tree does not solve this: it mounts the signer into the agent's own container, so\nits phase-1 boundary isolates agents from each other, not the signer from the agent. A hosted\ncomposition must run the manager/minter that holds the signer in a different uid, container, or mount\nnamespace from the agent children, which mount no signer at all; that split is future\nhosted-composition work, so until it (or a remote/injected minter) exists, do not run untrusted\nagents under this manager.\n\n## Provisioning a space (one-space reference shape)\n\n```ts\nimport { createSpaceAuth, setupSpaceStreams, ensureDefaultDeliveryClass, mintCreds, newIdentity } from \"@cotal-ai/core\";\nconst auth = await createSpaceAuth(space); // trust bundle (in-memory seeds)\nconst provisionerCreds = await mintCreds(auth, newIdentity(), \"provisioner\");\nawait setupSpaceStreams({ servers: brokerUrl, space, creds: provisionerCreds });\n// SPEC section 4: write the default delivery class at space creation so it is wire-discoverable,\n// never inferred from the resolution fallback. A daemon-backed space is \"durable\".\nawait ensureDefaultDeliveryClass({ servers: brokerUrl, space, creds: provisionerCreds, deliveryClass: \"durable\" });\nconst deliveryCreds = await mintCreds(auth, newIdentity(), \"delivery\");\n// put deliveryCreds into your SecretStore under deliveryCredsKey(space, { injected: true })\n// (@cotal-ai/workspace) before booting delivery \u2014 the key is per-space, not the bare kind.\n```\n\nRendering the broker config for a user-auth space is `serverConfig(broker, spaces, { storeDir,\nmaxFileStore?, extraAccounts })`, where `extraAccounts` must include the callout account from\n`createCalloutAuth` so the auth-service has a broker account to answer on. That account never shares\nthe data account. `maxFileStore` is an optional positive integer byte cap; any other value throws.\n\nBroker trust and space accounts are separate authorities: `createBrokerAuth` mints the one\noperator + system account a broker trusts, `createSpaceAccountAuth(broker, space)` signs each\ntenant's data account under it, and `serverConfig(broker, spaces, opts)` renders them all into one\nconfig. A host composition can therefore provision several spaces on one broker today. `cotal up`\nrenders that config from every tenant the root's auth directory holds, so booting one space keeps\nthe broker trusting its siblings, and it refuses to render at all while any account record is\nunreadable. The rest of the CLI lifecycle is still broker-wide: `down`, `clean` and `backup` refuse\non a multi-space root rather than scoping to one tenant, and the per-space lifecycle is the\nremaining multi-space operator layer. See\n[Known gaps](#hosted-composition-gaps).\n\n## Hazardous provisioning primitives\n\n`mintCreds`, the full `Profile`/`CredentialKind` matrix, `createSpaceAuth`, and `stripSpaceAuth` are\nlow-level operator primitives. Handle them as account-authority material:\n\n- A holder of a `SpaceAuth` (or a `stripSpaceAuth` bundle, which **keeps** the data signing seed) is\n a fully-trusted tenant-account authority: it can mint `admin`, `provisioner`, and destructive\n profiles, not merely `supervisor`, and mint a DM-reading identity. `createSpaceAuth`'s full result\n holds operator, system, and account seeds in memory.\n- Choose `profile` and `MintOpts` from **server-side constants**, never from tenant input. `MintOpts`\n can widen the bounded TTL defaults; cap it at your boundary. `CREDENTIAL_LIFETIMES` is a policy\n record, not an authorization boundary.\n- Never log signer material or export it into env. Do not co-locate signer access with an untrusted\n connector/runtime process at the same OS uid (file permissions do not contain a same-uid reader;\n see the manager's isolation note). Segregate per tenant; rotate on compromise\n (`rotateDataAccountSigningKey`).\n\n## Hosted composition gaps\n\nThe primitives above are present as exports, but three capabilities are **not** cleanly composable\nfrom the public contract today. Each is tied to work in flight; a host either waits for the seam or\nscopes the capability out. None is a wire concern.\n\n1. **Delivery immediate live eviction and a fully-hosted membership feed.** The renewable\n `membership-rw.creds` is now a `SecretStore` kind. `startMembership` reads it through the\n injected store, and the manager re-signs it there. The graph-feed writer therefore renews on a hosted\n backend (its data connection adopts each generation on a preflight-proven 75% timer). What still\n reads from a fixed on-disk path are the *static* `membership-observer.creds` and\n `connection-evictor.creds` ($SYS creds, minted at the `up` that provisions the account and renewed by `up --rotate-sys`) and `membership.json`\n (`{accountId}`, non-secret config); those, plus the private provisioning wrapper, keep immediate\n live eviction and a fully-hosted feed a partial gap. Missing files degrade membership to\n traffic-only and make live eviction refuse (loudly). The supported delivery contract here is the\n Plane-3 durable backstop.\n2. **Supervisor signer isolation.** `ManagerOptions.secretStore` now injects the one `SecretStore` the\n manager reads/writes every secret through, including the composed `SpaceAuth`\n signer (the split trust records), its daemon-cred renewal, and its per-agent kinds. What remains is process\n isolation: the manager still decrypts the signer in-process at its uid, so untrusted agent children\n must run under a different uid/container/mount namespace or behind a future remote signer.\n3. **Per-space lifecycle on a shared broker.** The trust layer is multi-space\n (`createBrokerAuth` + `createSpaceAccountAuth` + N-space `serverConfig`, persisted as\n `broker.json` + `account.<key>.json`) and `cotal up` renders the whole tenant list, but there is\n no per-space provisioning verb and no per-space teardown/backup/restore: the CLI's broker-wide\n lifecycle verbs refuse on a multi-space root, naming the tenants.\n This is the remaining multi-space operator layer.\n4. **A non-Better-Auth production IdP.** The exchange core (`createIdpBridge`) is EdDSA-generic, but\n the stock provider and login client are Better-Auth-endpoint-shaped, `cotalAuthProvider`\n self-registers on import (colliding with a host-owned provider under `resolveAuthProvider`), and\n the login flow speaks Better Auth's device-code endpoints. A different IdP is a host-built auth\n composition on the low-level primitives, not a configuration change (see\n [the IdP callout contract](identity-and-auth.md#the-idp-callout-contract)).\n\n## Hosted durability\n\nSpace-durable **coordination** state (chat/DM/task history, live presence, membership runtime, the\ndurable ACL registry, leases) lives in **JetStream**, written by the delivery daemon and the\nendpoints. It is broker-resident and needs no host-side durable path.\n\nWhat is **not** in JetStream, and is hosting-critical, is trust and authorization state a host must\nplace and keep:\n\n| state | class | where today | hosted injection |\n|---|---|---|---|\n| full `SpaceAuth` trust chain (`auth/broker.json` + `auth/account.<key>.json`, composed; a stripped signer bundle may instead be mounted at the legacy `auth/auth.json` key) | signing authority | `SecretStore` | `SecretStore` (manager + renewal) |\n| auth kinds: callout account/creds/xkey, issuer private keys, owner-derivation secret, data-signer projection | signing/identity authority | four `SecretStore` kinds | `SecretStore` (auth-service) |\n| `delivery.creds` | standing scoped cred | `SecretStore` or `--creds` | `SecretStore` (delivery) |\n| actor ledger, IdP pin | authorization + trust config | ambient `userAuthStateDir(findCotalRoot(), space)` | none (root-relative; not `store`/`COTAL_HOME`) |\n| `membership-rw.creds` | standing scoped cred | `SecretStore` | `SecretStore` (delivery + manager renewal) |\n| membership-observer / connection-evictor creds + `membership.json` | scoped $SYS creds / config | workspace filesystem | none (see gap 1) |\n| manager agent creds, actor tokens, sentinel creds | lifecycle authority | `SecretStore` | `SecretStore` (manager `secretStore`) |\n| `~/.cotal/meshes/space.<key>.json` record (holds IdP trust pins/root pointers) | non-secret, integrity-critical | machine home | process-global `COTAL_HOME` only |\n| auth-health, renewal records | non-secret diagnostics | workspace filesystem | `workspaceRoot` |\n\nThe `SpaceAuth` trust chain and the auth-service store kinds are **separate** identities/projections,\nnever parts of one document. `auth-service.json` (the live exchange capability) is ephemeral runtime\nstate, not durable, but is sensitive while the daemon runs. `@cotal-ai/workspace` is machine-local\noperator tooling by design; personas, PID files, and the `current-mesh` pointer are truly local and\nmust **not** sit on a hosted durable path. Everything classed above as an authority is what a hosted\ncomposition must provision and persist: signer-bearing server secrets now have `SecretStore` seams;\nthe remaining non-injectable rows are the explicit ambient `workspaceRoot`/cwd paths above.\n\n## See also\n\n- [Substrate stability](stability.md): what v0.3 and the 0.x packages guarantee, and the projected v0.4 break.\n- [Identity and auth](identity-and-auth.md): the profile matrix, the signer, and the IdP callout contract.\n- [Delivery daemon](delivery-daemon.md): the Plane-3 durable backstop.\n- [Deploy](deploy.md): the reference container against an external broker.\n"
19639
19639
  },
19640
19640
  {
19641
19641
  "slug": "examples",
@@ -19684,7 +19684,7 @@ var DOCS_BUNDLE = {
19684
19684
  "title": "Publishing a release",
19685
19685
  "kind": "Project (non-normative maintainer notes)",
19686
19686
  "summary": "Cotal uses Changesets to version and publish the workspace packages under packages/, extensions/, and implementations/ to npm.",
19687
- "body": "# Publishing a release\n\n> **Project** (non-normative maintainer notes) \xB7 **For:** maintainers shipping Cotal\n\nCotal uses [Changesets](https://github.com/changesets/changesets) to version and publish the\nworkspace packages under `packages/*`, `extensions/*`, and `implementations/*` to npm.\n`examples/**` is ignored, since it is not published.\n\n## 0.11 runtime migration\n\nThe published binary no longer bundles the optional tmux and cmux runtimes. Existing operators\nmust run `cotal ext add @cotal-ai/tmux` or `cotal ext add @cotal-ai/cmux` once after upgrading,\nbefore using `runtime: tmux|cmux` in a manifest or passing `--runtime tmux|cmux`. Missing runtimes\nfail loudly with the matching install command; they never fall back to pty.\n\n## Trusted publishing\n\nTrusted publishing replaces the long-lived `NPM_TOKEN` secret with short-lived OIDC tokens\nissued by GitHub Actions. Each published package must be configured once on npmjs.com.\n\nThe `fixed` group in [`.changeset/config.json`](../.changeset/config.json) is the list that\ngets versioned and published. Derive the package list from it instead of\nmaintaining it by hand. It had drifted by six packages before this was last reconciled.\n\n### Deployment Environment setup\n\nBoth publishing jobs (`version` and `snapshot`) reference a GitHub Environment named\n`npm-publish`. The OIDC assertion rejects tokens that do not carry this environment claim,\nso the Environment must exist and must protect the release ref before the first publish.\n\n1. Go to **Settings > Environments** in the repository.\n2. Create a new Environment named **`npm-publish`**.\n3. Under **Deployment branches and tags**, select **Selected branches and tags** and add\n `main` as the only allowed branch. This restricts OIDC token issuance to runs on `main`.\n4. Optionally add required reviewers if your team wants a manual gate before each release.\n\nThe Environment name is embedded in the workflow, in the OIDC identity assertion, and in\nevery npm trusted-publisher record. All three must use the exact string `npm-publish`.\n\n> **Snapshot releases:** the snapshot job is also bound to the `npm-publish` Environment.\n> If the deployment branch policy allows only `main`, snapshot releases from other branches\n> are refused by the Environment gate before the OIDC exchange. To allow snapshots from\n> additional branches, add those branches to the Environment's deployment policy.\n\n### Per-package trusted publisher configuration\n\nFor **every** published package, `cotal-ai` (the binary), `@cotal-ai/core`,\n`@cotal-ai/workspace`, `@cotal-ai/cli`, `@cotal-ai/manager`, `@cotal-ai/delivery`,\n`@cotal-ai/web`, `@cotal-ai/cmux`, `@cotal-ai/orca`, `@cotal-ai/tmux`, `@cotal-ai/herdr`,\n`@cotal-ai/connector-core`, `@cotal-ai/connector-claude-code`, `@cotal-ai/connector-hermes`,\n`@cotal-ai/connector-opencode`, `@cotal-ai/connector-codex`, `@cotal-ai/pi`, `@cotal-ai/auth`:\n\n1. Go to `https://www.npmjs.com/package/<name>/access` (e.g.\n `https://www.npmjs.com/package/@cotal-ai/core/access`).\n2. Scroll to **Trusted publishing** > **Add a trusted publisher**.\n3. Pick **GitHub Actions**.\n4. Fill in:\n - **Organization or user:** the GitHub owner (your org or user).\n - **Repository:** `Cotal`.\n - **Workflow filename:** `changesets.yml`.\n - **Environment name:** `npm-publish`.\n5. Save. Repeat for every package.\n\n> The first time, you may need to publish a version manually (with a classic token) so the\n> package exists on npm. After that, OIDC takes over.\n\n> **Migration from blank Environment:** if packages were previously configured with a blank\n> Environment name, each must be updated to `npm-publish`. Delete the old trusted publisher\n> record and re-create it with the Environment name filled in. The preflight will refuse any\n> package whose trusted-publisher record does not carry the `npm-publish` environment.\n\n## Day-to-day flow\n\n1. Open a PR that changes code in a publishable package.\n2. Add a changeset describing the change:\n\n ```bash\n pnpm changeset\n ```\n\n Pick the affected packages plus the semver bump (patch / minor / major), and write a\n one-line summary. Commit the generated `.changeset/<name>.md` file alongside your code\n change. On a branch that has been open for a long time, check that `main` has not already\n shipped the change before you trust its changeset. Merge conflicts in the code the changeset\n describes are the usual sign that it has.\n3. Merge to `main`. The only status check `main` requires is `attribution`. The CI aggregate\n jobs `ci-ok`, `windows-ok` and `installer-ok` are advisory: a red one reports and does not\n block the merge, so read them before you merge.\n4. The `Changesets` workflow runs:\n - If there are pending changesets, it opens (or updates) a PR titled `chore(release):\n version packages` that bumps versions and updates `CHANGELOG.md` files.\n - When **that** PR is merged, the same workflow detects the bumped versions, runs `pnpm\n build`, and `pnpm publish`es each changed package to npm with provenance.\n\n## Correcting a released changelog entry\n\nChangesets only prepends new `## <version>` sections, so an entry in a released section stays as\nwritten unless someone edits it. When a released entry is wrong, add a `**Correction:**` paragraph\nunder the same bullet, indented two spaces, in every `CHANGELOG.md` that carries the bullet. Keep the\noriginal text, since the GitHub Release for that version already published it. Use the same\nwording in each file, because `scripts/release-notes-detail.mjs` dedupes summaries by their text.\n\n## Publication workflow\n\n`ci:publish` in the root `package.json` is:\n\n- an exact-version census of every package in the Changesets fixed group against the registry;\n- a check that the public recursive workspace set is the complete Changesets fixed group;\n- one GitHub OIDC exchange per package when the release job exposes the OIDC requester;\n- a GET of each package's trusted-publisher document with that exchanged token, which must list\n a direct `npm publish` Allowed action on THIS repository's `changesets.yml` publisher;\n- only after those checks, the workspace build, native assembly, and recursive publish.\n\nThe preflight first refuses npm access-token environment variables, before invoking pnpm to\nenumerate workspace packages. The registry census prints each package, version, OIDC result and\ndirect-publish result. If every exact version already exists, the preflight reports a no-op before\nOIDC requests. A mixed census, incomplete fixed group, failed OIDC exchange, or stage-only package\nexits before `pnpm publish`.\n\nThe post-publish closure gate checks every package in the fixed group. Registry observations cannot\ndistinguish a partial publish from slow propagation: clean 404s and repeated non-404 failures both\nlack evidence that a package will never appear. The census therefore reports an incomplete or\nerrored closure as `UNSETTLED` and never fails the job on its own. Exit 1 remains reserved for future\npositive publisher evidence.\n\nPresence on the per-version endpoint does not prove a version installs: `npm install` resolves\nthrough the packument, which can lag that endpoint. Before the GitHub Release is cut, the install\ngate installs `cotal-ai@<version>` from the registry into a scratch prefix with a fresh cache and\nruns `cotal --version`. A failed attempt before the deadline counts as unknown and is retried every\n15 seconds. Once less than two intervals remain, the gate waits half of the remaining time instead,\nso the last failed attempt is still retried before the deadline. A version that does not install\nand run within 10 minutes fails the job, and no Release is cut:\n\n```bash\nnode scripts/verify-release-installable.mjs 0.52.0\n```\n\nThe window in which npm's `latest` tag points at a version whose pinned siblings are not yet\ninstallable opens at publish time, so neither gate can close it. They only keep the announcement\nout of it.\n\nWhen both gates pass, the job cuts the GitHub Release and tag for that version. The Release\ntargets the oldest commit on `main` whose `bin/package.json` carries the version, which is the\ntree the packages were built from. A version that was reverted and carried again keeps that first\ncommit. A publishing run whose closure gate ends `UNSETTLED` skips the Release, and the next push\nthat passes both gates cuts it with that same target. The step fails if it cannot read that history\nor cannot find that commit.\n\nAfter the `version` job, the `install-probe` job packs `cotal-ai` and each runtime sibling (every\n`workspace:` dependency of `cotal-ai`) and checks that each tarball contains its declared `main` and\nstring `exports` targets. It then extracts the `cotal-ai` tarball and runs `cotal --version` and\n`cotal --help`. The binary runs against the workspace copies of its siblings, so the probe checks\npackage shape only. It does not cover registry propagation or native assets. Nothing depends on the\njob, so a failure reds the workflow without gating the Release:\n\n```bash\nnode scripts/post-publish-install-probe.mjs\n```\n\nRe-check a version that already shipped without publishing, tagging, or changing git:\n\n```bash\nnode scripts/verify-publish-closure.mjs 0.52.0 --recheck\n```\n\nThe publish job refuses an npm access token in its environment and publishes through OIDC only.\nThis prevents pnpm from falling back to a classic token when an OIDC exchange fails.\n\nHTTP 201 from the OIDC exchange is identity only. npm's trusted-publisher Allowed actions always\npermit `npm stage publish`; configurations created after 2026-09-03 default to stage and may omit\ndirect `npm publish`. Both paths use the same successful exchange, so the preflight never treats\nthat 201 as proof that sequential `pnpm publish -r` can write. Binding those Allowed actions to a\nGitHub Environment is done: the `version` and `snapshot` jobs reference `environment:\nnpm-publish`, and the OIDC identity assertion rejects tokens without the matching\nenvironment claim.\n\npnpm's `--batch` option was evaluated. It exists from pnpm 11.7 and is all-or-nothing only on a\nregistry implementing `PUT /-/pnpm/v1/publish` (pnpr does). npm's registry returns 404 for read-only\n`GET` and `OPTIONS` probes of that endpoint, and its published Registry API does not document it.\npnpm batch publishing also rejects provenance and requires one shared credential for the batch\ninstead of the per-package OIDC exchanges used here. The repository stays on the normal npm publish\nprotocol and treats the preflight as the fail-before-first-write control.\n\n```bash\nnode scripts/preflight-npm-publish.mjs && pnpm build && node scripts/seat-assemble-natives.mjs && pnpm publish -r --provenance --access=public --no-git-checks\n```\n\n- `preflight-npm-publish.mjs`: derive and print the full fixed-group package/version census. In\n GitHub Actions it exchanges a package-specific OIDC token, then GETs `/-/package/<name>/trust`\n and refuses unless THIS repository's `changesets.yml` publisher lists a direct-publish Allowed\n action. npm documents that identity on GET `/-/package/<name>/trust` as `claims.repository` and\n `claims.workflow_ref.file` with a `permissions` array. Other GitHub publishers on the same package\n are not proof that this job can publish. It refuses npm access-token environment variables before\n the census or OIDC exchange, so `ci:publish` cannot be used with a classic token.\n- `pnpm build`: build every workspace package first, supplying local workspace dependency outputs\n when a partial retry publishes only the packages still missing.\n- `seat-assemble-natives.mjs`: assemble the downloaded native seat artifacts before publication.\n- Seat's pack and publish hooks assert both native artifacts and compile its JavaScript and type\n entrypoints without rebuilding the native helpers.\n- `-r`: recursively publish all workspace packages.\n- `--provenance`: emit SLSA provenance attestations (a no-op without OIDC, automatic with it).\n- `--access=public`: required for scoped packages on first publish.\n- `--no-git-checks`: skip pnpm's branch / clean-tree guard, since CI does not need it.\n"
19687
+ "body": "# Publishing a release\n\n> **Project** (non-normative maintainer notes) \xB7 **For:** maintainers shipping Cotal\n\nCotal uses [Changesets](https://github.com/changesets/changesets) to version and publish the\nworkspace packages under `packages/*`, `extensions/*`, and `implementations/*` to npm.\n`examples/**` is ignored, since it is not published.\n\n## 0.11 runtime migration\n\nThe published binary no longer bundles the optional tmux and cmux runtimes. Existing operators\nmust run `cotal ext add @cotal-ai/tmux` or `cotal ext add @cotal-ai/cmux` once after upgrading,\nbefore using `runtime: tmux|cmux` in a manifest or passing `--runtime tmux|cmux`. Missing runtimes\nfail loudly with the matching install command; they never fall back to pty.\n\n## Trusted publishing\n\nTrusted publishing replaces the long-lived `NPM_TOKEN` secret with short-lived OIDC tokens\nissued by GitHub Actions. Each published package must be configured once on npmjs.com.\n\nThe `fixed` group in [`.changeset/config.json`](../.changeset/config.json) is the list that\ngets versioned and published. Derive the package list from it instead of\nmaintaining it by hand. It had drifted by six packages before this was last reconciled.\n\n### Deployment Environment setup\n\nBoth publishing jobs (`version` and `snapshot`) reference a GitHub Environment named\n`npm-publish`. The OIDC assertion rejects tokens that do not carry this environment claim,\nso the Environment must exist and must protect the release ref before the first publish.\n\n1. Go to **Settings > Environments** in the repository.\n2. Create a new Environment named **`npm-publish`**.\n3. Under **Deployment branches and tags**, select **Selected branches and tags** and add\n `main` as the only allowed branch. This restricts OIDC token issuance to runs on `main`.\n4. Optionally add required reviewers if your team wants a manual gate before each release.\n\nThe Environment name is embedded in the workflow, in the OIDC identity assertion, and in\nevery npm trusted-publisher record. All three must use the exact string `npm-publish`.\n\n> **Snapshot releases:** the snapshot job is also bound to the `npm-publish` Environment.\n> If the deployment branch policy allows only `main`, snapshot releases from other branches\n> are refused by the Environment gate before the OIDC exchange. To allow snapshots from\n> additional branches, add those branches to the Environment's deployment policy.\n\n### Per-package trusted publisher configuration\n\nFor **every** published package, `cotal-ai` (the binary), `@cotal-ai/core`,\n`@cotal-ai/workspace`, `@cotal-ai/cli`, `@cotal-ai/manager`, `@cotal-ai/delivery`,\n`@cotal-ai/web`, `@cotal-ai/cmux`, `@cotal-ai/orca`, `@cotal-ai/tmux`, `@cotal-ai/herdr`,\n`@cotal-ai/connector-core`, `@cotal-ai/connector-claude-code`, `@cotal-ai/connector-hermes`,\n`@cotal-ai/connector-opencode`, `@cotal-ai/connector-codex`, `@cotal-ai/pi`, `@cotal-ai/auth`:\n\n1. Go to `https://www.npmjs.com/package/<name>/access` (e.g.\n `https://www.npmjs.com/package/@cotal-ai/core/access`).\n2. Scroll to **Trusted publishing** > **Add a trusted publisher**.\n3. Pick **GitHub Actions**.\n4. Fill in:\n - **Organization or user:** the GitHub owner (your org or user).\n - **Repository:** `Cotal`.\n - **Workflow filename:** `changesets.yml`.\n - **Environment name:** `npm-publish`.\n5. Save. Repeat for every package.\n\n> The first time, you may need to publish a version manually (with a classic token) so the\n> package exists on npm. After that, OIDC takes over.\n\n> **Migration from blank Environment:** if packages were previously configured with a blank\n> Environment name, each must be updated to `npm-publish`. Delete the old trusted publisher\n> record and re-create it with the Environment name filled in. The preflight will refuse any\n> package whose trusted-publisher record does not carry the `npm-publish` environment.\n\n## Day-to-day flow\n\n1. Open a PR that changes code in a publishable package.\n2. Add a changeset describing the change:\n\n ```bash\n pnpm changeset\n ```\n\n Pick the affected packages plus the semver bump (patch / minor / major), and write a\n one-line summary. Commit the generated `.changeset/<name>.md` file alongside your code\n change. On a branch that has been open for a long time, check that `main` has not already\n shipped the change before you trust its changeset. Merge conflicts in the code the changeset\n describes are the usual sign that it has.\n3. Merge to `main`. The only status check `main` requires is `attribution`. The CI aggregate\n jobs `ci-ok`, `windows-ok` and `installer-ok` are advisory: a red one reports and does not\n block the merge, so read them before you merge.\n4. The `Changesets` workflow runs:\n - If there are pending changesets, it opens (or updates) a PR titled `chore(release):\n version packages` that bumps versions and updates `CHANGELOG.md` files. The same step\n (`pnpm ci:version`) rebuilds the Claude connector and runs\n `scripts/materialize-claude-plugin.mjs`, which rewrites the committed Claude Code plugin tree\n under `claude-plugin/` with the new bundles and stamps both plugin manifests with the new\n version. Claude Code updates an installed plugin only when that version changes, so a release\n is what delivers a new plugin to installs from the repo's marketplace or a pinned commit.\n The bundles carry the docs, so `scripts/operator-literal-allowlist.json` lists them with the\n same example counts as `docs/cli.md` and `docs/run-a-mesh.md`. A release that changes those\n counts updates the bundle entries in the release PR.\n - When **that** PR is merged, the same workflow detects the bumped versions, runs `pnpm\n build`, and `pnpm publish`es each changed package to npm with provenance.\n\n## Correcting a released changelog entry\n\nChangesets only prepends new `## <version>` sections, so an entry in a released section stays as\nwritten unless someone edits it. When a released entry is wrong, add a `**Correction:**` paragraph\nunder the same bullet, indented two spaces, in every `CHANGELOG.md` that carries the bullet. Keep the\noriginal text, since the GitHub Release for that version already published it. Use the same\nwording in each file, because `scripts/release-notes-detail.mjs` dedupes summaries by their text.\n\n## Publication workflow\n\n`ci:publish` in the root `package.json` is:\n\n- an exact-version census of every package in the Changesets fixed group against the registry;\n- a check that the public recursive workspace set is the complete Changesets fixed group;\n- one GitHub OIDC exchange per package when the release job exposes the OIDC requester;\n- a GET of each package's trusted-publisher document with that exchanged token, which must list\n a direct `npm publish` Allowed action on THIS repository's `changesets.yml` publisher;\n- only after those checks, the workspace build, native assembly, and recursive publish.\n\nThe preflight first refuses npm access-token environment variables, before invoking pnpm to\nenumerate workspace packages. The registry census prints each package, version, OIDC result and\ndirect-publish result. If every exact version already exists, the preflight reports a no-op before\nOIDC requests. A mixed census, incomplete fixed group, failed OIDC exchange, or stage-only package\nexits before `pnpm publish`.\n\nThe post-publish closure gate checks every package in the fixed group. Registry observations cannot\ndistinguish a partial publish from slow propagation: clean 404s and repeated non-404 failures both\nlack evidence that a package will never appear. The census therefore reports an incomplete or\nerrored closure as `UNSETTLED` and never fails the job on its own. Exit 1 remains reserved for future\npositive publisher evidence.\n\nPresence on the per-version endpoint does not prove a version installs: `npm install` resolves\nthrough the packument, which can lag that endpoint. Before the GitHub Release is cut, the install\ngate installs `cotal-ai@<version>` from the registry into a scratch prefix with a fresh cache and\nruns `cotal --version`. A failed attempt before the deadline counts as unknown and is retried every\n15 seconds. Once less than two intervals remain, the gate waits half of the remaining time instead,\nso the last failed attempt is still retried before the deadline. A version that does not install\nand run within 10 minutes fails the job, and no Release is cut:\n\n```bash\nnode scripts/verify-release-installable.mjs 0.52.0\n```\n\nThe window in which npm's `latest` tag points at a version whose pinned siblings are not yet\ninstallable opens at publish time, so neither gate can close it. They only keep the announcement\nout of it.\n\nWhen both gates pass, the job cuts the GitHub Release and tag for that version. The Release\ntargets the oldest commit on `main` whose `bin/package.json` carries the version, which is the\ntree the packages were built from. A version that was reverted and carried again keeps that first\ncommit. A publishing run whose closure gate ends `UNSETTLED` skips the Release, and the next push\nthat passes both gates cuts it with that same target. The step fails if it cannot read that history\nor cannot find that commit.\n\nAfter the `version` job, the `install-probe` job packs `cotal-ai` and each runtime sibling (every\n`workspace:` dependency of `cotal-ai`) and checks that each tarball contains its declared `main` and\nstring `exports` targets. It then extracts the `cotal-ai` tarball and runs `cotal --version` and\n`cotal --help`. The binary runs against the workspace copies of its siblings, so the probe checks\npackage shape only. It does not cover registry propagation or native assets. Nothing depends on the\njob, so a failure reds the workflow without gating the Release:\n\n```bash\nnode scripts/post-publish-install-probe.mjs\n```\n\nRe-check a version that already shipped without publishing, tagging, or changing git:\n\n```bash\nnode scripts/verify-publish-closure.mjs 0.52.0 --recheck\n```\n\nThe publish job refuses an npm access token in its environment and publishes through OIDC only.\nThis prevents pnpm from falling back to a classic token when an OIDC exchange fails.\n\nHTTP 201 from the OIDC exchange is identity only. npm's trusted-publisher Allowed actions always\npermit `npm stage publish`; configurations created after 2026-09-03 default to stage and may omit\ndirect `npm publish`. Both paths use the same successful exchange, so the preflight never treats\nthat 201 as proof that sequential `pnpm publish -r` can write. Binding those Allowed actions to a\nGitHub Environment is done: the `version` and `snapshot` jobs reference `environment:\nnpm-publish`, and the OIDC identity assertion rejects tokens without the matching\nenvironment claim.\n\npnpm's `--batch` option was evaluated. It exists from pnpm 11.7 and is all-or-nothing only on a\nregistry implementing `PUT /-/pnpm/v1/publish` (pnpr does). npm's registry returns 404 for read-only\n`GET` and `OPTIONS` probes of that endpoint, and its published Registry API does not document it.\npnpm batch publishing also rejects provenance and requires one shared credential for the batch\ninstead of the per-package OIDC exchanges used here. The repository stays on the normal npm publish\nprotocol and treats the preflight as the fail-before-first-write control.\n\n```bash\nnode scripts/preflight-npm-publish.mjs && pnpm build && node scripts/seat-assemble-natives.mjs && pnpm publish -r --provenance --access=public --no-git-checks\n```\n\n- `preflight-npm-publish.mjs`: derive and print the full fixed-group package/version census. In\n GitHub Actions it exchanges a package-specific OIDC token, then GETs `/-/package/<name>/trust`\n and refuses unless THIS repository's `changesets.yml` publisher lists a direct-publish Allowed\n action. npm documents that identity on GET `/-/package/<name>/trust` as `claims.repository` and\n `claims.workflow_ref.file` with a `permissions` array. Other GitHub publishers on the same package\n are not proof that this job can publish. It refuses npm access-token environment variables before\n the census or OIDC exchange, so `ci:publish` cannot be used with a classic token.\n- `pnpm build`: build every workspace package first, supplying local workspace dependency outputs\n when a partial retry publishes only the packages still missing.\n- `seat-assemble-natives.mjs`: assemble the downloaded native seat artifacts before publication.\n- Seat's pack and publish hooks assert both native artifacts and compile its JavaScript and type\n entrypoints without rebuilding the native helpers.\n- `-r`: recursively publish all workspace packages.\n- `--provenance`: emit SLSA provenance attestations (a no-op without OIDC, automatic with it).\n- `--access=public`: required for scoped packages on first publish.\n- `--no-git-checks`: skip pnpm's branch / clean-tree guard, since CI does not need it.\n"
19688
19688
  },
19689
19689
  {
19690
19690
  "slug": "roadmap",
@@ -19712,7 +19712,7 @@ var DOCS_BUNDLE = {
19712
19712
  "title": "Setup internals (maintainer notes)",
19713
19713
  "kind": "Project (non-normative maintainer notes)",
19714
19714
  "summary": "cotal setup (implementations/cli/src/commands/setup.ts) is configure-only: it checks prerequisites, installs the Claude Code plugin, and seeds persona files, and it launches nothing: no mesh, no we\u2026",
19715
- "body": "# Setup internals (maintainer notes)\n\n> **Project** (non-normative maintainer notes) \xB7 **For:** maintainers changing how setup works\n>\n> How `cotal setup` works, and the cross-repo couplings it depends on. If you change one of\n> the things in the **Invariants** table, update the listed siblings in the same change, or\n> setup silently breaks for npx users.\n\n## The flow\n\n`cotal setup`\n([`implementations/cli/src/commands/setup.ts`](../implementations/cli/src/commands/setup.ts))\nis **configure-only**: it checks prerequisites, installs the Claude Code\nplugin, and seeds persona files, and it **launches nothing**: no mesh, no web dashboard, no\nmanager, no delivery daemon, no cmux/tmux session, no demo. Starting the stack is `cotal up`; the\ndashboard is `cotal web`. Every file it writes is announced (`\u2192 wrote \u2026` via `provenance.wrote`).\nIt is two-tier, gated on a machine marker. Persona seeding resolves the selected mesh root first,\nthen uses the same `.cotal/agents` catalog as spawn. With no mesh it names a cwd fallback; an\nambiguous or broken target refuses rather than choosing a root.\n\n**First run** (no `~/.cotal/onboarded.json`, or `--full`, or `--yes`) runs `runFirstRun(yes)`:\n\n- splash \u2192 intro \u2192 core **checks** (Node >= 22; **locate** `nats-server`: located, never\n started) \u2192 **connector picker** \u2192 resolve and announce the persona destination \u2192 seed the generic\n `default` and optional demo personas (david/sven/me) there \u2192 **offer a global install**\n (`offerGlobalInstall`) \u2192 onboarded marker \u2192 a finale that\n lists the commands to start things (`cotal up --detach`, `cotal web`, `cotal spawn \u2026`,\n `cotal console`, `cotal down`). Nothing is running when it returns.\n- The old `--auth` / `--open` flags are **gone**: they set the mesh MODE at launch time, and setup\n no longer launches; mode is now `cotal up [--open]`'s concern (an unknown-option error names\n them, no silent no-op).\n\n**Later runs** run `runEnsure`: resolve and announce the same destination, re-seed the `default`\npersona if it's missing,\nre-offer the **global install** (`offerGlobalInstall`, same `isNpx()` + PATH-scan gate as first\nrun, so a repeat `npx cotal-ai setup` on a machine that still lacks a durable `cotal` finally\ninstalls it), then print the **status card** (`readyCard`). The card is **read-only probes** (`machineStatus`/`connectorStatusRows`/`meshStatus`/`webUp`/`managerUp` for NATS, the rows\nconnector setup providers report, the mesh, the web dashboard, and the manager) and for anything down it prints the exact command to start it\n(`cotal up --detach`, `cotal web`, `cotal supervise`). Displaying state never depends on it; setup\nstill launches nothing.\n\n**`--skills`** is the status-card write: it asks installed connectors with a declared skills setup\nhook to reconcile their own harness, then reconciles `~/.agents/skills`. The base CLI passes only the\nvendor-neutral skills directory, version, and state directory; connector packages own native assets\nand commands. It does not seed personas, install the mesh\nconnector, offer a global install, or write the onboarded stamp. Combined with `--full` or\n`--demo` it is refused.\n\nThe seeded `default` persona has an empty active `subscribe` set and wildcard\n`allowSubscribe`/`allowPublish` ACLs. A fresh agent receives no channel traffic until it joins a\nchannel, but can join, create, read, and post to channels on demand. The guided demo personas keep\ntheir existing `welcome` read and post scope. Repeat setup replaces the prior default template only\nwhen its bytes still match the shipped legacy body with `allowPublish: []`; any user edit makes the\nfile ineligible and leaves it byte-identical.\n\nSteps run in-process via `runSteps`\n([`lib/steps.ts`](../implementations/cli/src/lib/steps.ts)). A step can be `optional` (asked\nY/n), carry a `confirm` consent prompt, or be `live` (it draws its own pane via\n[`lib/live-window.ts`](../implementations/cli/src/lib/live-window.ts)). On failure, an\ninteractive run offers a debug handoff for each connector whose setup provider declares an `assist`\nand whose executables are on PATH\n([`lib/assist.ts`](../implementations/cli/src/lib/assist.ts)). The provider owns the harness\nbinary and its flags; the CLI only builds the prompt. When no connector can host one, the menu says\nso in one line. A provider may also declare `status`, which returns read-only rows about what it\ninstalled. `cotal status` and the setup card print them, and the CLI passes only its own version and\nthe `cotal setup --skills` remedy. The extensions manifest caches each connector's setup ref, so\nstatus imports only connectors that declare a provider. The seed reconcile refreshes a seeded entry\nwhose cache predates that ref.\n\nThe **connector picker** (`pickConnectors`) multiselects the **setup connector surface**\n(`setupConnectorSurface`): every connector name the live registry or the installed extension\nmanifest advertises, materialized through the same loader the rest of the CLI uses. No connector\nname is written into `setup.ts`. `setupConnectorCandidates` turns that surface into choices and\nreads each hint off the connector's own declarations: `requires` names the executables a candidate\nstill needs on PATH, `setup` says whether it owns setup actions at all, and `pluginRoot` says\nwhether those actions install plugin assets. A selected candidate runs its connector-owned\n`connector` action through `connectorSetupStep`, which receives the `Connector` itself; a candidate\nthat declares no provider is simply marked ready (OpenCode auto-wires at spawn, injecting its\nplugin via `buildLaunch` and never writing the user's config). The `skills` action runs for every\npresent connector that declares one, selected or not, because Cotal's authored skills are\nindependent of mesh membership. Two experts (david, the engineer; sven, the guide) plus the\noperator's own driving session (`me`) are written by default, and `me` is the persona\n`cotal spawn me` drives.\n\n**`--yes`** forces non-interactive accept-all even on a TTY: optional plus `confirm` steps run\n(so the demo personas are written), the global install takes its default, and a failure aborts\nwith the log path and a non-zero exit. It still launches nothing. The control plane comes up with\n`cotal up --detach`. This is the agent/CI contract; keep it working.\n\n## Invariants\n\n| Thing | Must stay in sync across | Why |\n|---|---|---|\n| Marketplace name **`cotal-mesh`** | `setup.ts` (materialized `marketplace.json`), `CHANNEL_REF` in [`extensions/connector-claude-code/src/extension.ts`](../extensions/connector-claude-code/src/extension.ts), repo [`.claude-plugin/marketplace.json`](../.claude-plugin/marketplace.json) | The wake channel ref `plugin:cotal@cotal-mesh` binds by this name |\n| Plugin assets | the claude connector's own [`src/setup.ts`](../extensions/connector-claude-code/src/setup.ts) copy list (`dist/mcp.cjs`, `dist/hook.cjs`, `.claude-plugin/plugin.json`, `.mcp.json`, `hooks/hooks.json`) and its `package.json` `files` field | The connector materializes its own plugin; missing or renamed assets break the install, and the base CLI has no copy of the list |\n| `Connector.pluginRoot` | [`packages/core/src/connector.ts`](../packages/core/src/connector.ts) (contract) plus set in the claude connector's `extension.ts` | A connector's declaration that it ships installable plugin assets; the picker phrases its hint from it |\n| `BUNDLED_PKG_PREFIX` | [`lib/nats-bin.ts`](../implementations/cli/src/lib/nats-bin.ts) \u2194 the `@eplightning/nats-server-*` `optionalDependencies` in [`implementations/cli/package.json`](../implementations/cli/package.json) | The bundled NATS binary is resolved by `${prefix}-${platform}-${arch}`. (Future: swap the prefix to our own `@cotal-ai/nats-server-*`.) |\n| Onboard marker plus `ONBOARD_VERSION` | `~/.cotal/onboarded.json` in [`lib/onboard.ts`](../implementations/cli/src/lib/onboard.ts); version const in `setup.ts` | Flips first-run vs ensure |\n| Demo-agent format | `DEMO_AGENTS` in `setup.ts` matches the frontmatter shape read by [`packages/core/src/agent-file.ts`](../packages/core/src/agent-file.ts) (same as `examples/01-lateral-coordination/agents/`) | `cotal spawn <name>` loads these |\n| Managed personas | each `DEMO_AGENTS` body carries a `# managed by cotal-setup` frontmatter marker; `writeDemoAgent` refreshes the file when the body changes, backing a marker-less (user-edited) file up to `<name>.md.bak` first | Edit `DEMO_AGENTS` plus re-run setup to update david/sven/me; delete the marker line to take ownership |\n| `DEFAULT_SERVER` | [`packages/core/src/endpoint.ts`](../packages/core/src/endpoint.ts) | The address `cotal up` starts and the status card probes |\n\n## Background processes (`cotal up`)\n\n`cotal up` brings up the whole local stack in one place; since setup became configure-only\n(stage 2b), this is where the mesh and control plane start, so `cotal spawn --detach` /\n`cotal_spawn` find a manager right after `up`. The control plane comes up in cutover order:\nold-manager preflight \u2192 **delivery daemon** (auth mode only) \u2192 **manager**, via\n`ensureControlPlane`\n([`lib/delivery-proc.ts`](../implementations/cli/src/lib/delivery-proc.ts)). The detached\nprocesses, all stopped by `cotal down`:\n\nWith no explicit `--server`, `cotal up` auto-selects a free local port when the default broker\naddress is already held by another root or an unrecorded broker; an explicit `--server` remains\nfail-loud on collision.\n\n- **Mesh:** `startMeshDetached`\n ([`commands/up.ts`](../implementations/cli/src/commands/up.ts)) is the one place that boots a\n background nats-server (foreground `up` and `up --detach` both route through it). Writes\n `.cotal/nats.pid` and tails `.cotal/nats.log`.\n- **Delivery daemon:** `startDeliveryDetached` / `ensureDelivery`\n ([`lib/delivery-proc.ts`](../implementations/cli/src/lib/delivery-proc.ts)) re-execs `cotal\n deliver` detached with a pre-minted scoped `delivery.creds` (auth mode only, the durable\n backstop; open mode has none). Writes `.cotal/delivery.<key>.pid` and `.cotal/delivery.<key>.log`,\n where `<key>` is the space key ([Config](config.md#project-files)), so a root can serve a space per\n daemon.\n- **Manager:** `startManagerDetached` / `ensureManager`\n ([`lib/manager-proc.ts`](../implementations/cli/src/lib/manager-proc.ts)) re-execs `cotal\n supervise` detached (pty runtime); it answers the control plane\n (`cotal_spawn` / `cotal_despawn` / `cotal_persona`). Writes `.cotal/manager.<key>.log`;\n `managerUp(space)` checks that space's pid record for setup's status card. The **manager itself**\n writes `.cotal/manager.<key>.pid`, so a supervisor started by a container entrypoint, by cron, or\n by hand is recorded the same way a detached `cotal up` is. Readers verify the recorded pid is alive and is a\n supervisor before trusting it ([Config](config.md#project-files)).\n\nThe **web dashboard** is *not* part of `cotal up`. It ships inside `cotal-ai` as the `@cotal-ai/web`\nextension and is seeded automatically by the boot reconcile, the same durable, version-locked path as\nthe built-in connectors (`SEEDED_EXTENSIONS`), so it always matches the CLI version and needs no\nseparate install. Start it with `cotal web`; it records\n`.cotal/web.pid`, self-registers that process with `down`, and is addressed as\n`http://cotal.localhost:7799` (binds loopback; `*.localhost` resolves in Chrome/Firefox/Edge,\nSafari may need plain `127.0.0.1`). `webUp()` probes the port for setup's status card.\n\nAll recorded local processes self-register `local-process` descriptors. Bare `cotal down` resolves\nthe full set and stops it in dependency order; `cotal down manager` (or another component name)\nselects only that descriptor. Installed extensions cache their contributed registry keys, so the\nbase CLI does not hardcode optional package pidfiles.\n\nEach recorded pidfile also carries a sibling `<pidfile>.identity` pin (pid plus the process's\nstart, where the OS reports one). The pin proves that the live process is still the recorded one.\nA reused pid and a torn or unreadable pin are refused and preserved. A pre-pin record warns and is\nsignalled so an upgraded CLI can stop a stack launched by the previous version; the next launch\nwrites a pin. A record clears only once death is confirmed.\n\nAll re-execs resolve this CLI via `selfArgv()` / `selfCotal()`\n([`lib/self-exec.ts`](../implementations/cli/src/lib/self-exec.ts)) = `[node, ...loaderFlags,\nentry]` (tsx loader in dev, compiled JS in prod), so they never need `cotal` on PATH; the stack\ncomes up identically via `npx`, `npm i -g`, and a dev clone.\n\n`selfArgv()` throws unless `process.argv[1]` resolves, through any symlink, to the bin the\n`cotal-ai` package declares (`dist/cotal.js`) or to the `cotal.ts` beside that package's manifest\n(a checkout's `bin/cotal.ts`). Started from any other file, such as a smoke suite under tsx, a\nre-exec would run that file again with a subcommand it ignores, and a file that reaches a starter\non load would spawn its own successor (#1629). The auth, manager and delivery starters ask before\nthey touch a pidfile or a log, and `seedOne` asks before it writes its cursor, stages a payload or\nwrites its child marker, so a refusal on those paths leaves none of them behind.\n\nFor ergonomics only, an npx run with no global `cotal` offers to `npm i -g cotal-ai`\n(`offerGlobalInstall`, pinned to the running version): gated on `isNpx()` plus a PATH scan\n(`cotalOnPath()`, not `onPath(\"cotal\")`, since `cotal --version` is not a real command). The\ninteractive prompt defaults to yes, the non-interactive path (`--yes` or no TTY) takes the\ndefault, and a failed install is non-fatal (warn plus manual command). The same `self-exec.ts`\nexposes `displayCmd()`, the prefix (`cotal` / `npx cotal-ai` / `pnpm cotal`) used in the\nstatus-card hints so they match how you ran it.\n\n## Built-in connectors are seeded extensions\n\nThe first-party connectors (`claude`, `opencode`, `codex`, `hermes`, `pi`) are **not** static-imported by\nthe binary. The composition root (`bin/cotal.ts`) registers no connector; they self-register only when\nimported, and they are imported only once installed. On the first real command of each boot the CLI\n**seeds** them through the same `cotal ext add` path a third party uses, so they are ordinary\nextensions you can `cotal ext remove`. Code lives in [`implementations/cli/src/seed/`](../implementations/cli/src/seed/);\nthe entry is `reconcileSeededConnectors()`, gated in `runCli` before the manifest overlay so\n`ext seed --repair` survives a corrupt manifest.\n\n**What ships where.** The connectors are `devDependencies` of `cotal-ai` (not runtime deps), and a\n`prepack` step ([`bin/scripts/copy-seeded-connectors.mjs`](../bin/scripts/copy-seeded-connectors.mjs))\n`npm pack`s each into `bin/seeded-connectors/<name>/` (honoring each connector's own `files`), added to\nthe package `files`. `SEEDED_EXTENSIONS` (`@cotal-ai/workspace`) is the shared list: the\nconnectors plus `web`. The prepack asserts that every bundled payload's `name` and `version` match\nthe umbrella (the\n`fixed` changeset group keeps them lockstep), so a version-skewed payload can never be published; `web`\nalso emits `dist/web/vendor/vendor-manifest.json` (name/version/license/sha512) as the auditable\ninventory of its vendored browser libs (marked/DOMPurify ship as opaque `dist` bytes, not runtime deps).\n`seed/paths.ts:shippedSourceDir` resolves the live `extensions/<pkg>` dir in a\nsource checkout and `<cotal-ai>/seeded-connectors/<name>` in a published install. The reconcile copies\nthat payload into the durable store `seed/store/<version>/<name>`. The version is validated as one safe\npath segment, and the destination is checked to stay inside the store before anything is written. `ext add --install-links` reifies\nthe `file:` dep from THAT stable path (a volatile source would fail to re-reify); `ext add` then\njunction-links each `@cotal-ai/*` peer to the binary's own copy. Before the first lazy import in each\nprocess, materialization rechecks those links by realpath and rebinds stale links under the extension\nlock. This lets the registry-facing imports of a global install, npx, and source worktrees share the\nmachine prefix while each process still gets its host's single `@cotal-ai/core` registry instance;\nlauncher artifacts are self-contained and do not resolve those mutable links later.\n\n**Reconcile policy** (generation = the `cotal-ai` version): a never-seeded built-in is seeded; a\nstill-installed one WE seeded (`source: \"seeded\"`) is refreshed only when the version bumps (semver\ncompare) or under `--force`; an operator-managed official entry (a manual `ext add` at a chosen\nversion, no seeded marker) is left untouched on upgrade; a deliberately-removed one stays removed. The\n`ever-seeded` **authority** (`seed/authority.json`, mirrored to a monotonic `.bak`) is the sole arbiter\nof removed-vs-never-seeded and is unioned with its backup on read, so a truncated authority never\nresurrects a removal. Before writing the generation stamp, setup verifies that every\n(re)installed extension is recorded in the manifest, present on disk with a resolvable entry file,\nand at the generation version. A version-skewed payload fails loud (`ext seed --repair`) rather than being stamped as current. A cotal\n**older** than the store's stamped generation refuses before writing anything, rather than stamping the\nstore back down to its own version while refreshing nothing: run the newer cotal, or `ext seed --force`\nto rebuild the store for the version you are running. `--reset` is not that recovery: it discards the\never-seeded authority and resurrects deliberately-removed connectors. The refusal names a concrete cotal executable\nonly after a bounded `--version` probe proves that executable is at least the store generation;\notherwise it retains the generic instruction. A generation advance records the exact\nrealpath-resolved CLI entry and an ISO timestamp in `seed/stamp.json`, then announces the migration\nafter that stamp commits. An older CLI includes those fields in its refusal when present; legacy\ngeneration-only stamps stay valid and retain the shorter refusal. A CLI whose package root is the\nrepo `bin/` (a source checkout, including a suite child of `bin/cotal.ts`) refuses that write, stamp,\nand generation GC rather than migrating the operator-global store. The refusal names\n`$XDG_CONFIG_HOME` as the isolation remedy; `COTAL_HOME` does not relocate this store. Isolated\nin-tree seed smokes set `COTAL_ALLOW_CHECKOUT_SEED=1` after pointing `$XDG_CONFIG_HOME` at a scratch\ndir. An unproven entry is refused the same way: a missing identity answer is not treated as a\nreleased install.\n\n**Crash safety.** One shared advisory lock ([`packages/workspace/src/advisory-lock.ts`](../packages/workspace/src/advisory-lock.ts):\natomic hard-link publish, PID + process-start liveness, bounded wait, dead-owner reclaim) guards the\nwhole reconcile and every `cotal ext` mutation; a live reconcile is waited on, not mistaken for a crash.\nA crash **cursor** is journaled before each connector mutation and cleared only at the final commit, so\na SIGKILL mid-run is detected on the next boot (fail loud \u2192 `ext seed --repair` re-installs the\ninterrupted connector before it clears the evidence). Seed children are authenticated (they carry the\nlive lock's nonce + parent PID, not a bare env flag) and record a liveness marker so a post-crash repair\nrefuses to race an orphaned installer. `ext seed --reset` quarantines corrupt manifest/authority state\naside and rebuilds. See [cli.md `ext`](cli.md#ext) for the operator-facing flags.\n"
19715
+ "body": "# Setup internals (maintainer notes)\n\n> **Project** (non-normative maintainer notes) \xB7 **For:** maintainers changing how setup works\n>\n> How `cotal setup` works, and the cross-repo couplings it depends on. If you change one of\n> the things in the **Invariants** table, update the listed siblings in the same change, or\n> setup silently breaks for npx users.\n\n## The flow\n\n`cotal setup`\n([`implementations/cli/src/commands/setup.ts`](../implementations/cli/src/commands/setup.ts))\nis **configure-only**: it checks prerequisites, installs the Claude Code\nplugin, and seeds persona files, and it **launches nothing**: no mesh, no web dashboard, no\nmanager, no delivery daemon, no cmux/tmux session, no demo. Starting the stack is `cotal up`; the\ndashboard is `cotal web`. Every file it writes is announced (`\u2192 wrote \u2026` via `provenance.wrote`).\nIt is two-tier, gated on a machine marker. Persona seeding resolves the selected mesh root first,\nthen uses the same `.cotal/agents` catalog as spawn. With no mesh it names a cwd fallback; an\nambiguous or broken target refuses rather than choosing a root.\n\n**First run** (no `~/.cotal/onboarded.json`, or `--full`, or `--yes`) runs `runFirstRun(yes)`:\n\n- splash \u2192 intro \u2192 core **checks** (Node >= 22; **locate** `nats-server`: located, never\n started) \u2192 **connector picker** \u2192 resolve and announce the persona destination \u2192 seed the generic\n `default` and optional demo personas (david/sven/me) there \u2192 **offer a global install**\n (`offerGlobalInstall`) \u2192 onboarded marker \u2192 a finale that\n lists the commands to start things (`cotal up --detach`, `cotal web`, `cotal spawn \u2026`,\n `cotal console`, `cotal down`). Nothing is running when it returns.\n- The old `--auth` / `--open` flags are **gone**: they set the mesh MODE at launch time, and setup\n no longer launches; mode is now `cotal up [--open]`'s concern (an unknown-option error names\n them, no silent no-op).\n\n**Later runs** run `runEnsure`: resolve and announce the same destination, re-seed the `default`\npersona if it's missing,\nre-offer the **global install** (`offerGlobalInstall`, same `isNpx()` + PATH-scan gate as first\nrun, so a repeat `npx cotal-ai setup` on a machine that still lacks a durable `cotal` finally\ninstalls it), then print the **status card** (`readyCard`). The card is **read-only probes** (`machineStatus`/`connectorStatusRows`/`meshStatus`/`webUp`/`managerUp` for NATS, the rows\nconnector setup providers report, the mesh, the web dashboard, and the manager) and for anything down it prints the exact command to start it\n(`cotal up --detach`, `cotal web`, `cotal supervise`). Displaying state never depends on it; setup\nstill launches nothing.\n\n**`--skills`** is the status-card write: it asks installed connectors with a declared skills setup\nhook to reconcile their own harness, then reconciles `~/.agents/skills`. The base CLI passes only the\nvendor-neutral skills directory, version, and state directory; connector packages own native assets\nand commands. It does not seed personas, install the mesh\nconnector, offer a global install, or write the onboarded stamp. Combined with `--full` or\n`--demo` it is refused.\n\nThe seeded `default` persona has an empty active `subscribe` set and wildcard\n`allowSubscribe`/`allowPublish` ACLs. A fresh agent receives no channel traffic until it joins a\nchannel, but can join, create, read, and post to channels on demand. The guided demo personas keep\ntheir existing `welcome` read and post scope. Repeat setup replaces the prior default template only\nwhen its bytes still match the shipped legacy body with `allowPublish: []`; any user edit makes the\nfile ineligible and leaves it byte-identical.\n\nSteps run in-process via `runSteps`\n([`lib/steps.ts`](../implementations/cli/src/lib/steps.ts)). A step can be `optional` (asked\nY/n), carry a `confirm` consent prompt, or be `live` (it draws its own pane via\n[`lib/live-window.ts`](../implementations/cli/src/lib/live-window.ts)). On failure, an\ninteractive run offers a debug handoff for each connector whose setup provider declares an `assist`\nand whose executables are on PATH\n([`lib/assist.ts`](../implementations/cli/src/lib/assist.ts)). The provider owns the harness\nbinary and its flags; the CLI only builds the prompt. When no connector can host one, the menu says\nso in one line. A provider may also declare `status`, which returns read-only rows about what it\ninstalled. `cotal status` and the setup card print them, and the CLI passes only its own version and\nthe `cotal setup --skills` remedy. The extensions manifest caches each connector's setup ref, so\nstatus imports only connectors that declare a provider. The seed reconcile refreshes a seeded entry\nwhose cache predates that ref.\n\nThe **connector picker** (`pickConnectors`) multiselects the **setup connector surface**\n(`setupConnectorSurface`): every connector name the live registry or the installed extension\nmanifest advertises, materialized through the same loader the rest of the CLI uses. No connector\nname is written into `setup.ts`. `setupConnectorCandidates` turns that surface into choices and\nreads each hint off the connector's own declarations: `requires` names the executables a candidate\nstill needs on PATH, `setup` says whether it owns setup actions at all, and `pluginRoot` says\nwhether those actions install plugin assets. A selected candidate runs its connector-owned\n`connector` action through `connectorSetupStep`, which receives the `Connector` itself; a candidate\nthat declares no provider is simply marked ready (OpenCode auto-wires at spawn, injecting its\nplugin via `buildLaunch` and never writing the user's config). The `skills` action runs for every\npresent connector that declares one, selected or not, because Cotal's authored skills are\nindependent of mesh membership. Two experts (david, the engineer; sven, the guide) plus the\noperator's own driving session (`me`) are written by default, and `me` is the persona\n`cotal spawn me` drives.\n\n**`--yes`** forces non-interactive accept-all even on a TTY: optional plus `confirm` steps run\n(so the demo personas are written), the global install takes its default, and a failure aborts\nwith the log path and a non-zero exit. It still launches nothing. The control plane comes up with\n`cotal up --detach`. This is the agent/CI contract; keep it working.\n\n## Invariants\n\n| Thing | Must stay in sync across | Why |\n|---|---|---|\n| Marketplace name **`cotal-mesh`** | `setup.ts` (materialized `marketplace.json`), `CHANNEL_REF` in [`extensions/connector-claude-code/src/extension.ts`](../extensions/connector-claude-code/src/extension.ts), repo [`.claude-plugin/marketplace.json`](../.claude-plugin/marketplace.json) | The wake channel ref `plugin:cotal@cotal-mesh` binds by this name |\n| Plugin assets | the claude connector's own [`src/setup.ts`](../extensions/connector-claude-code/src/setup.ts) copy list (`dist/mcp.cjs`, `dist/hook.cjs`, `.claude-plugin/plugin.json`, `.mcp.json`, `hooks/hooks.json`), its `package.json` `files` field, and the release tree's list in [`scripts/materialize-claude-plugin.mjs`](../scripts/materialize-claude-plugin.mjs) | The connector materializes its own plugin; missing or renamed assets break the install, and the base CLI has no copy of the list. The release script refuses a plugin whose `.mcp.json` or hooks reference a file it did not copy |\n| `Connector.pluginRoot` | [`packages/core/src/connector.ts`](../packages/core/src/connector.ts) (contract) plus set in the claude connector's `extension.ts` | A connector's declaration that it ships installable plugin assets; the picker phrases its hint from it |\n| `BUNDLED_PKG_PREFIX` | [`lib/nats-bin.ts`](../implementations/cli/src/lib/nats-bin.ts) \u2194 the `@eplightning/nats-server-*` `optionalDependencies` in [`implementations/cli/package.json`](../implementations/cli/package.json) | The bundled NATS binary is resolved by `${prefix}-${platform}-${arch}`. (Future: swap the prefix to our own `@cotal-ai/nats-server-*`.) |\n| Onboard marker plus `ONBOARD_VERSION` | `~/.cotal/onboarded.json` in [`lib/onboard.ts`](../implementations/cli/src/lib/onboard.ts); version const in `setup.ts` | Flips first-run vs ensure |\n| Demo-agent format | `DEMO_AGENTS` in `setup.ts` matches the frontmatter shape read by [`packages/core/src/agent-file.ts`](../packages/core/src/agent-file.ts) (same as `examples/01-lateral-coordination/agents/`) | `cotal spawn <name>` loads these |\n| Managed personas | each `DEMO_AGENTS` body carries a `# managed by cotal-setup` frontmatter marker; `writeDemoAgent` refreshes the file when the body changes, backing a marker-less (user-edited) file up to `<name>.md.bak` first | Edit `DEMO_AGENTS` plus re-run setup to update david/sven/me; delete the marker line to take ownership |\n| `DEFAULT_SERVER` | [`packages/core/src/endpoint.ts`](../packages/core/src/endpoint.ts) | The address `cotal up` starts and the status card probes |\n\n## Background processes (`cotal up`)\n\n`cotal up` brings up the whole local stack in one place; since setup became configure-only\n(stage 2b), this is where the mesh and control plane start, so `cotal spawn --detach` /\n`cotal_spawn` find a manager right after `up`. The control plane comes up in cutover order:\nold-manager preflight \u2192 **delivery daemon** (auth mode only) \u2192 **manager**, via\n`ensureControlPlane`\n([`lib/delivery-proc.ts`](../implementations/cli/src/lib/delivery-proc.ts)). The detached\nprocesses, all stopped by `cotal down`:\n\nWith no explicit `--server`, `cotal up` auto-selects a free local port when the default broker\naddress is already held by another root or an unrecorded broker; an explicit `--server` remains\nfail-loud on collision.\n\n- **Mesh:** `startMeshDetached`\n ([`commands/up.ts`](../implementations/cli/src/commands/up.ts)) is the one place that boots a\n background nats-server (foreground `up` and `up --detach` both route through it). Writes\n `.cotal/nats.pid` and tails `.cotal/nats.log`.\n- **Delivery daemon:** `startDeliveryDetached` / `ensureDelivery`\n ([`lib/delivery-proc.ts`](../implementations/cli/src/lib/delivery-proc.ts)) re-execs `cotal\n deliver` detached with a pre-minted scoped `delivery.creds` (auth mode only, the durable\n backstop; open mode has none). Writes `.cotal/delivery.<key>.pid` and `.cotal/delivery.<key>.log`,\n where `<key>` is the space key ([Config](config.md#project-files)), so a root can serve a space per\n daemon.\n- **Manager:** `startManagerDetached` / `ensureManager`\n ([`lib/manager-proc.ts`](../implementations/cli/src/lib/manager-proc.ts)) re-execs `cotal\n supervise` detached (pty runtime); it answers the control plane\n (`cotal_spawn` / `cotal_despawn` / `cotal_persona`). Writes `.cotal/manager.<key>.log`;\n `managerUp(space)` checks that space's pid record for setup's status card. The **manager itself**\n writes `.cotal/manager.<key>.pid`, so a supervisor started by a container entrypoint, by cron, or\n by hand is recorded the same way a detached `cotal up` is. Readers verify the recorded pid is alive and is a\n supervisor before trusting it ([Config](config.md#project-files)).\n\nThe **web dashboard** is *not* part of `cotal up`. It ships inside `cotal-ai` as the `@cotal-ai/web`\nextension and is seeded automatically by the boot reconcile, the same durable, version-locked path as\nthe built-in connectors (`SEEDED_EXTENSIONS`), so it always matches the CLI version and needs no\nseparate install. Start it with `cotal web`; it records\n`.cotal/web.pid`, self-registers that process with `down`, and is addressed as\n`http://cotal.localhost:7799` (binds loopback; `*.localhost` resolves in Chrome/Firefox/Edge,\nSafari may need plain `127.0.0.1`). `webUp()` probes the port for setup's status card.\n\nAll recorded local processes self-register `local-process` descriptors. Bare `cotal down` resolves\nthe full set and stops it in dependency order; `cotal down manager` (or another component name)\nselects only that descriptor. Installed extensions cache their contributed registry keys, so the\nbase CLI does not hardcode optional package pidfiles.\n\nEach recorded pidfile also carries a sibling `<pidfile>.identity` pin (pid plus the process's\nstart, where the OS reports one). The pin proves that the live process is still the recorded one.\nA reused pid and a torn or unreadable pin are refused and preserved. A pre-pin record warns and is\nsignalled so an upgraded CLI can stop a stack launched by the previous version; the next launch\nwrites a pin. A record clears only once death is confirmed.\n\nAll re-execs resolve this CLI via `selfArgv()` / `selfCotal()`\n([`lib/self-exec.ts`](../implementations/cli/src/lib/self-exec.ts)) = `[node, ...loaderFlags,\nentry]` (tsx loader in dev, compiled JS in prod), so they never need `cotal` on PATH; the stack\ncomes up identically via `npx`, `npm i -g`, and a dev clone.\n\n`selfArgv()` throws unless `process.argv[1]` resolves, through any symlink, to the bin the\n`cotal-ai` package declares (`dist/cotal.js`) or to the `cotal.ts` beside that package's manifest\n(a checkout's `bin/cotal.ts`). Started from any other file, such as a smoke suite under tsx, a\nre-exec would run that file again with a subcommand it ignores, and a file that reaches a starter\non load would spawn its own successor (#1629). The auth, manager and delivery starters ask before\nthey touch a pidfile or a log, and `seedOne` asks before it writes its cursor, stages a payload or\nwrites its child marker, so a refusal on those paths leaves none of them behind.\n\nFor ergonomics only, an npx run with no global `cotal` offers to `npm i -g cotal-ai`\n(`offerGlobalInstall`, pinned to the running version): gated on `isNpx()` plus a PATH scan\n(`cotalOnPath()`, not `onPath(\"cotal\")`, since `cotal --version` is not a real command). The\ninteractive prompt defaults to yes, the non-interactive path (`--yes` or no TTY) takes the\ndefault, and a failed install is non-fatal (warn plus manual command). The same `self-exec.ts`\nexposes `displayCmd()`, the prefix (`cotal` / `npx cotal-ai` / `pnpm cotal`) used in the\nstatus-card hints so they match how you ran it.\n\n## Built-in connectors are seeded extensions\n\nThe first-party connectors (`claude`, `opencode`, `codex`, `hermes`, `pi`) are **not** static-imported by\nthe binary. The composition root (`bin/cotal.ts`) registers no connector; they self-register only when\nimported, and they are imported only once installed. On the first real command of each boot the CLI\n**seeds** them through the same `cotal ext add` path a third party uses, so they are ordinary\nextensions you can `cotal ext remove`. Code lives in [`implementations/cli/src/seed/`](../implementations/cli/src/seed/);\nthe entry is `reconcileSeededConnectors()`, gated in `runCli` before the manifest overlay so\n`ext seed --repair` survives a corrupt manifest.\n\n**What ships where.** The connectors are `devDependencies` of `cotal-ai` (not runtime deps), and a\n`prepack` step ([`bin/scripts/copy-seeded-connectors.mjs`](../bin/scripts/copy-seeded-connectors.mjs))\n`npm pack`s each into `bin/seeded-connectors/<name>/` (honoring each connector's own `files`), added to\nthe package `files`. `SEEDED_EXTENSIONS` (`@cotal-ai/workspace`) is the shared list: the\nconnectors plus `web`. The prepack asserts that every bundled payload's `name` and `version` match\nthe umbrella (the\n`fixed` changeset group keeps them lockstep), so a version-skewed payload can never be published; `web`\nalso emits `dist/web/vendor/vendor-manifest.json` (name/version/license/sha512) as the auditable\ninventory of its vendored browser libs (marked/DOMPurify ship as opaque `dist` bytes, not runtime deps).\n`seed/paths.ts:shippedSourceDir` resolves the live `extensions/<pkg>` dir in a\nsource checkout and `<cotal-ai>/seeded-connectors/<name>` in a published install. The reconcile copies\nthat payload into the durable store `seed/store/<version>/<name>`. The version is validated as one safe\npath segment, and the destination is checked to stay inside the store before anything is written. `ext add --install-links` reifies\nthe `file:` dep from THAT stable path (a volatile source would fail to re-reify); `ext add` then\njunction-links each `@cotal-ai/*` peer to the binary's own copy. Before the first lazy import in each\nprocess, materialization rechecks those links by realpath and rebinds stale links under the extension\nlock. This lets the registry-facing imports of a global install, npx, and source worktrees share the\nmachine prefix while each process still gets its host's single `@cotal-ai/core` registry instance;\nlauncher artifacts are self-contained and do not resolve those mutable links later.\n\n**Reconcile policy** (generation = the `cotal-ai` version): a never-seeded built-in is seeded; a\nstill-installed one WE seeded (`source: \"seeded\"`) is refreshed only when the version bumps (semver\ncompare) or under `--force`; an operator-managed official entry (a manual `ext add` at a chosen\nversion, no seeded marker) is left untouched on upgrade; a deliberately-removed one stays removed. The\n`ever-seeded` **authority** (`seed/authority.json`, mirrored to a monotonic `.bak`) is the sole arbiter\nof removed-vs-never-seeded and is unioned with its backup on read, so a truncated authority never\nresurrects a removal. Before writing the generation stamp, setup verifies that every\n(re)installed extension is recorded in the manifest, present on disk with a resolvable entry file,\nand at the generation version. A version-skewed payload fails loud (`ext seed --repair`) rather than being stamped as current. A cotal\n**older** than the store's stamped generation refuses before writing anything, rather than stamping the\nstore back down to its own version while refreshing nothing: run the newer cotal, or `ext seed --force`\nto rebuild the store for the version you are running. `--reset` is not that recovery: it discards the\never-seeded authority and resurrects deliberately-removed connectors. The refusal names a concrete cotal executable\nonly after a bounded `--version` probe proves that executable is at least the store generation;\notherwise it retains the generic instruction. A generation advance records the exact\nrealpath-resolved CLI entry and an ISO timestamp in `seed/stamp.json`, then announces the migration\nafter that stamp commits. An older CLI includes those fields in its refusal when present; legacy\ngeneration-only stamps stay valid and retain the shorter refusal. A CLI whose package root is the\nrepo `bin/` (a source checkout, including a suite child of `bin/cotal.ts`) refuses that write, stamp,\nand generation GC rather than migrating the operator-global store. The refusal names\n`$XDG_CONFIG_HOME` as the isolation remedy; `COTAL_HOME` does not relocate this store. Isolated\nin-tree seed smokes set `COTAL_ALLOW_CHECKOUT_SEED=1` after pointing `$XDG_CONFIG_HOME` at a scratch\ndir. An unproven entry is refused the same way: a missing identity answer is not treated as a\nreleased install.\n\n**Crash safety.** One shared advisory lock ([`packages/workspace/src/advisory-lock.ts`](../packages/workspace/src/advisory-lock.ts):\natomic hard-link publish, PID + process-start liveness, bounded wait, dead-owner reclaim) guards the\nwhole reconcile and every `cotal ext` mutation; a live reconcile is waited on, not mistaken for a crash.\nA crash **cursor** is journaled before each connector mutation and cleared only at the final commit, so\na SIGKILL mid-run is detected on the next boot (fail loud \u2192 `ext seed --repair` re-installs the\ninterrupted connector before it clears the evidence). Seed children are authenticated (they carry the\nlive lock's nonce + parent PID, not a bare env flag) and record a liveness marker so a post-crash repair\nrefuses to race an orphaned installer. `ext seed --reset` quarantines corrupt manifest/authority state\naside and rebuilds. See [cli.md `ext`](cli.md#ext) for the operator-facing flags.\n"
19716
19716
  },
19717
19717
  {
19718
19718
  "slug": "spaces",