# SCP reference implementation

This document is non-normative. It describes how the reference implementation (the `swarm` CLI, its MCP server, its harness hooks, the broker, the hub and the hosted relay) does what the specification leaves to implementations: harness discovery, launching and waking workers, file layout, environment variables, timings, command-line presentation and per-harness integrations. The specification is `SPEC.md`; where the two disagree, `SPEC.md` wins. References of the form "SPEC section 6.4" point there. Requirement keywords that appear here describe the reference's own behaviour; they are not conformance requirements.

Normative interoperation on Unix hosts (socket discovery, harness-instance keys, grant conveyance, session markers and shim bindings) is fixed by SPEC Appendix E, not by this document.

## Surfaces

- `swarm <verb>` is the CLI (see CLI verbs). `swarm mcp` runs the MCP server, registered in each harness under the name `scp` (see MCP server). `swarm hook <harness> [event]` runs the harness hooks (see Harness bindings).
- The relay directory is `$SCP_DIR`, else `$XDG_STATE_HOME/scp`, else `$HOME/.local/state/scp` (SPEC Appendix E.1). User configuration lives in `~/.config/scp`: `config.json` (install settings such as the join mode), `policy.json`, `wakers.json` and `runners.json`. The bridge configuration is `cloud.json` in the relay directory.

### Environment variables

Variables marked "binding" are fixed by SPEC Appendix E; the others are this implementation's.

| Variable | Meaning |
| --- | --- |
| `SCP_DIR` | The relay directory (binding). |
| `SCP_SESSION` | The session key is `env:<value>` (binding). |
| `SCP_GRANT` | A grant to redeem; set by the spawn launcher for its worker (binding). |
| `SCP_NAME` | The handle to claim or act as; with a grant, the grant's name (binding for workers). |
| `SCP_UNDER` | Sent as the claim's `under`; set for a worker on another machine (binding). |
| `SCP_SHIM_BINDING` | The launch key of a shim binding (binding). |
| `SCP_HARNESS_PID` | The launcher that stands in as the harness instance (binding). |
| `SCP_HARNESS`, `SCP_HARNESS_DETECT` | Name the harness explicitly; `SCP_HARNESS_DETECT=0` turns off marker-based inference (binding, for shim bindings). |
| `SCP_PARENT` | The spawner's address, removed from a worker's environment. |
| `SCP_LABELS` | Labels passed to a worker's claims. |
| `SCP_PUSH_WAIT` | Caps the stop push wait. |
| `SCP_TLDR_EVERY` | Prompts between now-line reminders (default 10, 0 disables). |
| `SCP_TRANSPORT` | `cloud` sends the CLI to the hub directly; hooks then do nothing. |
| `SCP_CHANNELS` | Turns on channel push in the MCP server. |
| `SCP_RUNNERS_FILE` | Overrides the path of `runners.json`. |
| `SCP_MACHINE_WAKE_SECRET` | Default variable holding a machine waker's secret. |
| `SCP_WAKE_*` | The wake request, in a waker's environment. |

## Local broker

- The relay directory and socket follow the Unix binding (SPEC Appendix E).
- The client starts the broker on demand and retries the connection every 50 ms for up to 5 s. Of the hooks, only the session-start hook starts a broker (retrying for up to 1 s); any other hook finding none does nothing.
- The broker sweeps expired state every 10 minutes and recomputes presence every 60 s. It exits after an idle period with no connections and no pending requests (default 30 minutes).
- On shutdown the broker allows pending requests up to 2 s to finish.

## Identity

### Process keys and sessions

- Process keys and session keys follow SPEC Appendix E.3.
- The harness process of an MCP server is its parent process, or its grandparent when the parent is a shell. Under `swarm agy` and `swarm grok` it is the launcher instead, named by `SCP_HARNESS_PID`, which counts only when that pid is the parent of the harness process: the launcher holds the endpoint across the harness processes of its turns, so each turn's MCP server rejoins it from the same process key (SPEC section 6.4 rule 1). A hook takes the harness pid from the harness's environment where the harness provides one.
- A codex shim publishes its full harness process key in local binding files for sandboxed shells ("Self-attestation of tool shells"). `SCP_SHIM_BINDING` selects a binding across pid namespaces; it does not replace the process or session key.

### Self-attestation of tool shells

An agent's shell commands run as children of its harness. A CLI that runs in such a shell authenticates as the agent's endpoint without any token:

1. The CLI reads the process table once and computes `lineage` (process keys from its parent upward) and `self` (the harness lineage).
2. The harness lineage walk follows SPEC Appendix E.4.
3. When the harness lineage is empty in a codex shell, the CLI tries a local shim binding:
   - Codex markers are `CODEX_THREAD_ID`, then `CODEX_SANDBOX` (such as `seatbelt`) or `CODEX_SANDBOX_NETWORK_DISABLED`. `SCP_HARNESS=codex` also selects the fallback. `SCP_HARNESS_DETECT=0` disables marker-based inference, but not an explicit `SCP_HARNESS`.
   - The codex MCP shim writes `<relay dir>/bindings/pid-<harness pid>.json` containing `{process, handle, harness}`, with mode `0600`, and removes it on exit when possible. The process MUST be a full `pid:<pid>:<start time>` key.
   - `swarm codex` sets a fresh random 32-character hex `SCP_SHIM_BINDING` in the harness environment and passes it to the shim through `env_vars`. The shim also writes `bindings/key-<SCP_SHIM_BINDING>.json`. The CLI tries this binding first, so a pid namespace can recover the host harness key. `swarm spawn` removes the spawner's binding key from the worker's environment.
   - Without a usable key binding, the CLI climbs from its parent through the visible process table to the first missing parent pid and tries that pid's binding. The binding's process pid MUST equal the missing pid.
   - With a valid codex binding, both `lineage` and `self` contain the visible keys followed by the binding's process key, and the harness is codex. Without one, `swarm join` reports that the codex sandbox hides the harness and directs the agent to `relay_join` (MCP) with the name and `under` it was given.
   - A binding does not open the socket: a sandbox that refuses the connection to the relay socket ("Joining and linking from the CLI") makes the CLI name the MCP tools instead.
4. The CLI sends `hello {observe, self, lineage}`. The broker binds the endpoint that `self` places with auth `self`.

### Identity resolution in the CLI

The CLI resolves its identity in this order: an explicit name (`--as` or `SCP_NAME`), sent as an `exact` claim; else a grant in `SCP_GRANT`, sent as an observer with grant; else, when a terminal is attached or `--human` is given, a human hello with lineage (acting as the agent that lineage lands on, if any); else an observer with `self`.

### Subagents

- Claude Code reports a subagent's description through the root's `PostToolUse` of its `Agent` or `Task` tool, with `tool_input.description` and an object `tool_response` carrying `agentId`. The hook offers it as `subagent_title`.
- The subagent's `id` is Claude Code's `agentId`, which its SendMessage tool takes to continue a stopped subagent. A root under `swarm claude` (`channels`) or in a background session (`claude-inbox`) is the subagents' waker (SPEC section 6.9): its MCP server's channel push, the waker connection, also pushes a stopped subagent's messages, framed for the root to continue that subagent and carrying `for` and `subagent` in the channel metadata. The broker returns the root's waiting `inbox` call as soon as such a message arrives.

### Joining and linking from the CLI

- The default join mode is `auto`. `swarm setup --join manual` selects `manual` and stores it as `join` in `~/.config/scp/config.json`.
- `swarm adopt <child> --parent <p>` at a terminal without explicit `--as` acts as the human, ignoring inherited `$SCP_NAME` and `$SCP_GRANT`.
- `swarm adopt <child>` from an agent's own session or from its shell (lineage or self auth, "Self-attestation of tool shells") links a local root it may message under that agent at once and prints `<child> now sits under <agent>`; a child under another parent is asked to consent instead, and the command prints the request's short id.
- A link request is accepted with `swarm link accept <id>` or the MCP tool `relay_link_accept {id}`, once the child is a root. The child may instead consent with `swarm join --under <parent>` or `relay_join {under: <parent>}`.
- `swarm join --under <parent>` in a codex shell needs what a plain `swarm join` needs and nothing more: the harness instance, recovered from the shim binding, and a connection to the relay socket. Codex's workspace-write sandbox without network access refuses Unix domain socket connections too, so every relay command from its shell fails there; the CLI then exits with a usage error that names the MCP tool doing the same from outside the sandbox (`relay_join {name?, under?}` for a join, the scp MCP tools for the other verbs that act as the agent) and the codex setting that lets the sandbox reach the socket (`sandbox_workspace_write.network_access = true`), and starts no broker: a command from a codex shell never starts one, since the sandbox would hold the daemon too, and names the same tool and setting when none runs. Without a binding it names `relay_join` with the same arguments.
- A session renamed through its marker claims its own handle over `SCP_NAME` and the derived default.
- The CLI sends `$SCP_UNDER` as the claim's `under`.

## Delivery

### Hooks

- A hook exits successfully and writes nothing to standard error. It works within a deadline of 1500 ms (900 ms for harnesses with short hook timeouts; 1000 ms at session end), skips `inject` when less than 300 ms remain, and skips printing and confirming when less than 150 ms remain.
- On a subagent's first claim the hook prints the subagent's own address and that its shell acts as its parent unless given `--as <handle>`.
- The prompt-submit hook appends the tl;dr reminder when it is due (see "Titles, wakes and the now line").
- `swarm hook --no-inject` records activity only.
- `--block-stop-on-unread` sets `hold_unread` at the stop.
- With the transport overridden to the hub (`SCP_TRANSPORT=cloud`) hooks do nothing.

### Channel push

A harness whose tool protocol supports server-initiated messages MAY receive messages as they arrive. The reference MCP server, when started with channels enabled and connected to a client that declares the `claude/channel` experimental capability, or to one the reference launcher started with channels loaded, polls `inbox {wait_s: LIMITS.injectHoldS, offer: true}`, emits `notifications/claude/channel` with `{content: <preamble and block>, meta: {from, id, seq, thread, for?, subagent?}}` (`for` and `subagent` on a message for an idle subagent, SPEC section 6.9), and confirms with `injected {seqs, hold}`. Channel pushes do not acknowledge.

### Stop push

- The push wait is configured per install: default 600 s, 0 disables, range 0 to 3600. The effective wait is the smaller of the install setting and `SCP_PUSH_WAIT`.
- A wakeable session (SPEC section 8.6), such as one under `swarm claude`, `swarm claude --bg` or `swarm codex`, does not wait: its stop shows only the worker messages already unread and ends the turn, and the waker brings the later ones. A waker that connects ends a waiting or gathering stop.
- The installed Claude Code and Codex stop hooks with a push wait carry `"statusMessage": "waiting for worker messages (swarm)"`, which the harness shows while the stop runs. A prompt-submit hook that runs during the wait ends it (`prompted`); whether a prompt typed then runs it at once or only after the stop is up to the harness.
- The broker counts a spawn grant's bound process, and a hook-only worker's harness process, as running while its pid runs and `ps -o lstart=` (C locale; a match is cached 5 s, a mismatch read again) gives the start time its key holds, or none.

## Spawn launcher

The spawn operation (reference: `swarm spawn [flags] -- <harness command>`) runs a worker harness under a fresh spawn grant:

1. Mint a spawn grant with `ttl_s` 3600, `bind_process` set to the spawner's own process key, and the defaults of SPEC section 9.2.
2. Build the worker's environment from the spawner's minus the spawner's identity variables (those of SPEC Appendix E.5, plus `SCP_SPAWN_RUNNER` and `CODEX_INTERNAL_ORIGINATOR_OVERRIDE`), plus the grant's environment and labels.
3. Append the steer note (SPEC section 8.7) to the worker's prompt where the harness takes a prompt argument, and apply harness-specific arguments that let the worker use its relay tools.
4. Run the harness, forwarding signals, and capture the tail of its output when standard output is not a terminal.
5. When the worker exits 0 and its harness resumes a headless session (reference: `codex exec`), call `spawn_steers {grant, take: true}`. It returns the harness session id the worker's hooks reported and the spawner's messages to the worker it never read; with a session and such steers they are marked read, and the spawn resumes the session with them as the prompt, at most twice. A resumed run that exits non-zero leaves those steers `unapplied` (SPEC section 9.3).
6. On exit, call `spawn_steers {grant}` once more for the worker's transcript (where its hooks named one, the worker's own replies in it are the output), then `spawn_exit {grant, code, output, stderr?, unapplied?, exits?, started?}`.
7. Before that `spawn_exit`, hold the worker for resumed runs (SPEC section 9.5) when its harness resumes a headless session and the worker joined, unless spawn caught SIGINT, SIGTERM or SIGHUP during its runs. The resumable harnesses and their resumed run, the prompt last: `codex exec [opts] resume [the options resume takes] <session> <prompt>` (the session from `spawn_steers`; not with `--ephemeral`), `agy [args] --conversation <id> -p <prompt>` for a headless agy (a prompt flag), the id from the file named in `SCP_AGY_CONVERSATION_FILE`, a file in a temporary directory spawn sets for the worker and removes when it ends (else the user's `--conversation`), `grok [args] --resume <id> [--allow rules] -p <prompt>` for a headless grok, the id the user's `-s`/`-r`, else a new UUID spawn passes on the first run as `-s <uuid>`, and `muse [opts] exec [opts] --session-id <id> <prompt>` for a `muse exec` with at most one prompt word, which continues the logged session of that id: the id the user's `--session-id`, else a new UUID spawn passes on the first run right after `exec`; the resumed run leaves out `--prompt-file`, `--image` and the worktree options (`-w`/`--worktree`, `--worktree-base`, `--worktree-existing`), which belong to the first run, and a muse with `--no-session-log` (it keeps no session) or `--api-key-stdin` (its stdin is closed on a resume) is not resumed. The arguments are those of "Harness bindings" for `swarm agy` and `swarm grok`. The hold claims the worker's handle with the spent grant, `exact`, the worker's harness, spawn's own process key, the spawner's lineage, and a fresh `SCP_SESSION`; it sets `wake {via: "spawn-resume"}`. Each resumed run gets the worker's environment plus that `SCP_SESSION` and spawn's pid as `SCP_HARNESS_PID`, both also named in codex's `env_vars`, so the run's MCP server and hooks join the held endpoint ("Process keys and sessions"); its stdin is closed. The batch of messages taken while no run was in flight is the prompt, each framed as a channel push, joined by a blank line. Before each resumed run the endpoint's status is set `working` with an empty note; after it, `spawn_exit {grant, code, output, stderr?, run: n, reply_to?}` hands it back, `reply_to` the newest message the run carried from the worker's parent.
8. Let the worker go after `--idle` seconds without a message (default 1800 for a detached spawn, a `--durable` one or an MCP `relay_spawn`; in the foreground none unless `--idle` or `--durable` is given, so `--fg` keeps returning the harness's exit code at once; `Infinity` holds until ended; `0` never holds), on SIGINT, SIGTERM or SIGHUP (a run in flight gets the signal and finishes first), or when its holding connection closes (`swarm end`, or the broker going away). Before letting go after the idle time it takes once more any message that came meanwhile. With `--durable` it first sends `hibernate {wake: "spawn", ref}` on the holding connection and waits for the answer. A foreground spawn returns the first run's exit code.

**Durable spawns.** `--durable` hands the broker, with `wake {via: "spawn-resume", ref}`, the JSON ref `{"harness", "session", "cwd", "argv", "idle_s", "parent"}`: the resumable harness, its session, the absolute working directory, the resumed run's words before its prompt with the program found on `PATH` as an absolute path, the idle seconds (`null` for `Infinity`) and the parent's address. It never holds a credential or the grant. A command spawn cannot resume, `--idle 0`, or a ref that would pass `LIMITS.wakeRefChars` or look like it carries a credential is a usage error before the grant is minted. A broker without a `spawn` waker refuses the ref; the worker is then held plainly, with a note.

`spawn_env` (reference: `swarm spawn-env`) mints an ordinary grant with the same parameters and prints its environment, without steer key, handback or push.

**Launcher attribution.** A harness launcher (`swarm claude`, `codex`, `muse`, `pi`, `opencode`, `agy`, `grok`, `t3code`; "Harness bindings") run without `--root` mints, on the caller's behalf, the grant `swarm spawn-env --name N` would mint in the same shell, N being the launcher's `--name` (or `--as`), else none; no title. It connects to the broker the session will use (for `muse`, the one Muse's shim reaches) without starting one and binds as `spawn-env` would: `hello {observe, lineage, self, grant?}` from the shell's process walk, with no `self` when a terminal is attached and no `SCP_GRANT` is set. A `SCP_GRANT` that binds this shell is the caller's own (a spawned agent's spent grant) and is replaced; one that does not bind it was minted for the session and applies as is. The launcher then reads its own address with `rules`, refuses when `SCP_NAME` names another agent than that address (it warns and launches unchanged), and calls `grant`. So the caller's self or lineage attestation decides the authority, and a lineage caller's grant is narrow only (SPEC section 9.1). No broker, or no endpoint for `rules`, means no agent runs the shell: nothing is minted and nothing is said. With a grant, the session's environment is the caller's minus the identity variables of step 2 above, plus the grant's (`SCP_GRANT`, and `SCP_NAME` when named); the launcher then sets its own variables and passes the grant as it passes `SCP_GRANT` (for `swarm claude --bg`, in the MCP config's `env`), and `swarm claude --bg` prints `under <parent address>` after the session's address. Any other failure, or no answer within 5 s, prints one line to stderr once a harness or agent was found, and the launcher starts the session unlinked, without the caller's identity once the caller is known; it never refuses to launch over attribution. `--root` starts a root: no grant, and the caller's identity variables dropped. A launch that resumes or continues a stored session (`claude -c`/`-r`/`--resume`/`--from-pr`, `codex --resume` or `resume`, `muse --resume`, `grok -r`/`--resume`, `agy --conversation`, `pi -c`/`-r`/`--session`, `opencode -c`/`-s`) mints nothing and keeps its placement, as does the cloud transport.

With `--via <runner>` the spawn mints no grant and runs no harness here: a configured runner starts the worker on another machine, linked under the spawner ("Runners"). A spawn whose `--under` names an agent on another machine runs the harness linked under it, without a grant ("Runners").

### Worker display

- `swarm list` and the MCP `relay_list` show an idle agent without a wake as `idle (next turn)`, and a hibernated one as `hibernated (wakeable)`.
- The broker checks for ended harness processes every 60 s.

## Policy file

The broker reads an optional policy file (reference: `~/.config/scp/policy.json` under the home directory from the user database, overridable at broker start). It re-reads the file on every send, ask, hold release and policy call, and re-parses only when the text changed. A missing file is an open policy. An unreadable or invalid file MUST cause every send to be refused `policy_refused` with the error. Unknown fields are refused.

## Titles, wakes and the now line

- **Inferred title.** A hook of the holding process MAY offer `title_infer {from, text}` for an agent whose title nobody set (not a grant, `title` or the user). `from: "prompt"` is the first non-empty line of the session's first prompt and fills only an empty title. `from: "summary"` is the harness's own name for the session: Claude Code's newest `custom-title`, `ai-title` or `summary` line within the last 128 KiB of its transcript, or OpenCode's session title once it is not the placeholder. A summary replaces a prompt-derived title at once and a summary-derived one at most every `LIMITS.titleRefreshTurns` (5) prompts. The text is redacted (SPEC section 7.8), collapsed to one line and clipped to 64 characters; it is not audited. An inferred title never replaces a title set explicitly; an explicit `title` replaces an inferred one, and clearing it lets inference fill it again. Harnesses that expose neither (for example Codex, which names no session) get only the prompt-derived title. A subagent endpoint's inferred title is the description its root gave it (`subagent_title`, SPEC section 6.9).
- **Wake.** How an idle agent is woken by a message: `channels` (the MCP server pushes it by channel, SPEC section 8.4), `pi` (the same, to the pi extension, "Harness bindings"), `opencode-plugin` (the OpenCode plugin's waiter), `codex-app-server` (the `swarm codex` launcher), `claude-inbox` (the MCP server queues it on the Claude Code session's own inbox socket), `agy-resume` and `grok-resume` (the `swarm agy` and `swarm grok` launchers run a headless turn that resumes the conversation, "Harness bindings"), `muse-serve` (the `swarm muse` launcher), `t3code` (the `swarm t3code` launcher, "Harness bindings"), `spawn-resume` (a `swarm spawn` holding its exited worker, "Spawn launcher") or `none`. `wake {via}` sets it for the caller's endpoint: an agent's own session, a hook of its holding process, or a connection bound to it by its own harness process (`observe` with `self`, or with a grant); another lineage-bound connection is `unauthorized`, an unknown value or a non-agent endpoint `invalid_params`. A hook of another process changes nothing. `none` clears it. The result is `{changed}`. The connection that sets it is the endpoint's waker (SPEC section 9.4). A leave and an incarnation clear it, and so does the session's end (a hook's `session_end`, its harness process exiting, its spawn exiting) while no waker lives, and its last waker leaving once a hook waker's grace ran out. The reference MCP server, when started with channels enabled (SPEC section 8.4), sends it after its `hello` once `initialize` said the client takes channel pushes (or the reference launcher started it with channels loaded): `pi` when the client's `clientInfo.name` is `pi`, else `channels`, and again on every reconnect; a Claude Code client (`clientInfo.name` `claude-code`) without channels loaded, as in a background session, whose environment names `CLAUDE_CODE_MESSAGING_SOCKET` gets each delivery queued as its next turn on that socket (an `auth` line with `CLAUDE_CODE_MESSAGING_TOKEN` when set, then one `user` line with its `session_id`) and `claude-inbox`; the codex launcher's channel sends `codex-app-server` on its connection bound to the mounted server's endpoint; the agy and grok launchers' channels send `agy-resume` and `grok-resume` on the endpoint they hold; the muse launcher's channel sends `muse-serve` on its connection bound to the session's endpoint; the t3code launcher's channel sends `t3code` on the endpoint it holds; a spawn holding its worker sends `spawn-resume`, with `ref` when durable, on the connection that holds it; the OpenCode plugin's waiter sends `opencode-plugin` after its `hello`, from load on, and opens a session for a message that arrives before any is open; `swarm opencode [--name N | --as N]` runs OpenCode with `SCP_NAME=N`, harness `opencode` and wake on, and refuses to start without the current plugin. A client that cannot wake its session sends nothing, and the endpoint reads at its next turn. A failed `wake` never fails the connection. The bridge mirrors it with the agent's view; a hub keeps a value it does not know as none. While the agent is hibernated (SPEC section 19.1) its view shows `wake: "hook:<provider>"` instead, a value `wake {via}` never takes, and `wake_failed` (the reason) after a failed wake hook run or `wake_declined` after a declined one.
- **tl;dr reminder.** The note is the agent's tl;dr. A prompt-submit hook sends `prompted {tldr_every}`, from `SCP_TLDR_EVERY` (reference default 10, 0 disables). When at least that many prompts passed since the note last changed or the last reminder, the result carries `tldr: true`, the reminder counts, and the hook appends one line to what it injects: ``SCP: update your tl;dr with `swarm set-now "..."` or relay_set_now (what you are doing now).``

### Listing

The CLI shows only live endpoints (online, idle or hibernated) and the human by default, offline ones too with `--all` (`all: true`), and folds root agents that have no children (other than the caller) into one count line unless `--all` or `--roots` is given. The MCP `relay_list` shows live agents only, and offline ones too with `all`. The CLI's `list` has a `WAKE` column: the agent's wake (`hook:<provider>` while hibernated), `turn` for an agent without one, `-` for the human.

## Hosted-relay profile

- Credentials. The hosted relay issues two kinds, told apart by prefix: a device credential `scp_d_<secret>` from the device login below, bound to the member who approved it, and an API token `scp_t_<secret>` minted in the relay's console for ambient agents, which carries the label `token=<name>`. A credential is at most 128 characters. A hub MAY use other formats; a client treats the credential as opaque.
- Device login (an RFC 8628 profile). Bodies are JSON objects in both directions (an empty body counts as `{}`; form encoding is not accepted).
  - `POST /v1/device/code` takes `{label?}`, a machine-name-shaped label for the credential (default `cli`; the CLI sends the machine name), and answers `{device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval}` (`user_code` `XXXX-XXXX`, `expires_in` 900, `interval` 5).
  - A person signed in to the relay's console opens `verification_uri_complete`, picks an organisation they belong to (one with no organisation must create or join one first) and approves or denies.
  - The client polls `POST /v1/device/token` with `{device_code}` (any `grant_type` is ignored) no faster than `interval`. Until approval the answer is 400 `{error: "authorization_pending", error_description}`; other errors are 400 with `error` `invalid_grant` (unknown or already redeemed code), `access_denied` or `expired_token`, and end the flow. A client SHOULD also honour `slow_down` by adding 5 s to its interval. Success is 200 `{access_token, token_type: "Bearer", credential_id, org, email, label, relay}`; `access_token` is the credential.
  - `GET /v1/whoami` with the bearer header checks a credential: 200 `{kind: "device" | "token", org, email, role, label, ambient, endpoint_labels, expires_at}` (`endpoint_labels` are the credential labels the hub forces on endpoints, below), or 401 as above.

## Bridge and contacts

- The CLI sets the `machine` label on claims to the host name; a linked broker rewrites it to its machine name.
- The bridge configuration lives in `cloud.json` in the relay directory; `mirror_local` defaults to on.
- The CLI sends `bridge_relink` after `login`, `cloud enable` and `up`.
- The reference hub reads contacts through a host link; contact management belongs to the host and is not part of the reference implementation. The hosted relay has no such link: an address of another account is not found there and `list {contacts}` is empty.
- The CLI only lists contacts' endpoints (`list --contacts`).

## Mesh

- The mesh is off until `swarm mesh init`, and while `mesh.json` says `enabled: false`. A CLI built without the mesh warns once when `mesh.json` enables it and runs without it.
- Key files: `account.key` is `{v: 1, account, public, private}` (43-character base64url JWK `x` and `d`); `machine.key` is `{v: 1, public, private}`.
- Files live under `<state dir>/mesh` (directory mode 0700): `mesh.json` (`{v: 1, enabled, account, machine, listen, peers}`), `account.key` and `machine.key`, mode 0600.
- The peer list in `mesh.json` is re-read every 5 s.

**CLI.** `swarm mesh <verb>`:

| Verb | Effect |
| --- | --- |
| `init [--account <handle>] [--account-key <file>] [--machine <name>] [--listen <host:port>] [--force]` | Needs `--account` or `--account-key` (which must agree; `-` reads the key from stdin); `--machine` defaults to the short hostname. Creates or imports `account.key` (an existing key for the same account is kept), ensures `machine.key` and writes `mesh.json` (`enabled: true`). An existing `mesh.json` needs `--force`, which keeps its peers. |
| `add-peer <host:port>` | Adds a peer to `mesh.json`. |
| `rm-peer <host:port>` | Removes a peer from `mesh.json`. |
| `listen <host:port\|off>` | Sets or clears `listen` in `mesh.json`. |
| `on`, `off` | Enables or disables the mesh in `mesh.json`. |
| `status [--json]` | Shows `mesh.json` and live state from `cloud_status`, which gains `mesh {account, acct, listen, peers, links, machines}`. |
| `help` | Prints usage; every verb also takes `--help`. |

Changes take effect when the broker restarts (`init`, `listen`, `on` and `off` say so), except peers, which are re-read every 5 s.

## Judge

- The reference hub takes the egress and ingress sides as a pluggable cross-account gate that passes by default; the hosted-side behaviour of SPEC sections 15.5 to 15.7 is not part of the reference implementation. The hosted relay does not federate and judges only on the `local` side.

### Model call

- The body is passed between per-call nonce delimiters `<<<RELAY_DATA_<nonce>>>`.
- Verdicts are cached in memory (at most 1000): `pass` and `redact` for 24 h, `refuse` for 1 h.
- Model providers: `openai`, `openrouter` and `anthropic`.

### Hosted relay judge

- The hosted relay's judge is a `local`-side judge per org, off until an org owner turns it on in the console. The policy is fixed (`enforce`, fail `open`, `timeout_s` 12, effort `medium`, boundary `*` to `*`, no budgets or hold cap); the deployment sets the provider (`openrouter` by default, `openai` or `anthropic`), the model and the key, with no default model. Without a model or a key nothing is judged, pre-filter included.

- Local holds are swept every 10 minutes; holds of the egress and ingress gates every 15 minutes.

## Wakers and runners

- The broker refuses `hibernate` for a provider with no configured waker, `invalid_params` "no waker `<p>` is configured".
- The broker's presence check (every 60 s) retries a due wake hook for a hibernated endpoint with unread mail.
- `swarm hibernate` requests a hibernation from the agent's shell.
- The broker started by `swarm daemon` has the waker of provider `spawn` built in (`swarm spawn-wake`, timeout 60 s), so a durable spawn needs no `wakers.json`; an entry `spawn` there replaces it.

### Wake hooks

Before delivering to a hibernated local agent the broker runs its provider's waker, which resumes the session; the session then reclaims its endpoint and drains its queue.

- **Configuration.** `~/.config/scp/wakers.json`, beside the policy file, maps provider names to `{command: [argv...], timeout_s?}`; other fields are ignored. A name matches `^[a-z0-9][a-z0-9-]{0,31}$`; `command` is a non-empty list of strings, run without a shell; `timeout_s` is whole seconds, 1 to 3600, default 60. The broker reads the file again when it changes. A malformed file is logged once and configures nothing.
- **Trigger.** Every delivery to a local endpoint, a bridge's inbound delivery, a judge release and a handback runs the waker when one is due: the endpoint is hibernated and local, has an unread message, no run of its waker is in flight, and none ran within `LIMITS.wakeDebounceS` (60 s); after `n` failed runs in a row the wait is `min(LIMITS.wakeDebounceS * 2^n, LIMITS.wakeBackoffMaxS)` (900 s). The waker never blocks the delivery.
- **Input.** stdin is one JSON line, then end of input: `{"scp": 1, "event": "wake", "endpoint": <address>, "handle", "provider", "ref" (string or null), "machine", "message": {"id", "from", "kind", "thread", "created_at"}, "unread": <count>}`, from the newest unread message. It never carries a body, attachments or a credential. The environment is the broker's plus `SCP_WAKE_ENDPOINT`, `SCP_WAKE_HANDLE`, `SCP_WAKE_REF`, `SCP_WAKE_PROVIDER`, `SCP_WAKE_MESSAGE_ID`, `SCP_WAKE_FROM`, `SCP_WAKE_KIND`, `SCP_WAKE_THREAD`, for provider `spawn` the run's one-time wake ticket `SCP_WAKE_TICKET` (SPEC section 19.2), and `SCP_DIR` naming the broker's state directory, so the waker's own `swarm` calls reach this broker.
- **Result.** Exit 0 means the provider is resuming the session. Any other exit, a waker that cannot start, or one still running after `timeout_s` (its process group is killed) is a failure: the endpoint stays hibernated, its message stays queued, and its view shows `wake_failed` with the reason (`exit <code>` and the last line of stderr, or the timeout). A success clears the failure count and note; the hibernation itself clears only when the session reclaims its endpoint (SPEC section 19.1).
- **Decline.** Exit 77 with `{"declined": "<reason>"}` on stdout declines the wake: not a failure. The reason is a token the provider picks (SPEC section 19.1). The endpoint stays hibernated with its message queued, its view shows `wake_declined` with the reason, deliveries to it show `wake: "declined"`, and the failure count and backoff do not move. No further run happens for that message or another of its sender; a newer message from another sender, or a new `hibernate`, runs it again. Exit 77 with anything else on stdout is a failure.
- **Owner.** The waker runs on the broker of the account that owns the hibernated endpoint, with that user's local privileges. Any credentials the resumed session needs are the waker's to provision, from that account only (SPEC section 19).
- **Audit.** Each run is audited `wake {target, provider, ok, code?, ms, declined? | error?}`, the error cleaned, redacted and cut to 200 characters; never a body or the ref. Watchers see the endpoint's presence event with `wake_failed` or `wake_declined`.
- **Built-in spawn waker.** `swarm spawn-wake` reads the request above and parses `ref` as a durable spawn wrote it ("Spawn launcher"). A ref it cannot parse, an unknown harness or a missing directory exits 77 with `{"declined": "unresumable"}`. Otherwise it starts a detached runner in the worker's directory and exits 0 once the runner holds the endpoint, or 1 with the reason when the runner could not take it back within 10 s. The runner claims the handle `exact`, with the ref's harness, its own process key, a fresh `SCP_SESSION`, the ref's `cwd` and `ticket` from `SCP_WAKE_TICKET`, which takes the row back as its resumed session, labels, groups, peek and all (SPEC sections 6.4 rule 6 and 19.2); it retries while the spawn that hibernated the worker still lets go. Without a ticket it fails. The resumed harness never sees the ticket. It then holds the endpoint as a spawn does, durably with the same ref, and its first resumed run carries the unread messages. Each run hands back with `send {to: <parent>, kind: "message", body, reply_to?}` as the worker, over a second connection resumed with the claim's token before the first run (a run's hooks replace the token; `spawn_end` closes only the holding connection, so a run in flight still hands back), the body of a linked worker's handback ("Runners"), then sets `done` with `exited <code>`. It lets the endpoint go after the ref's idle time, hibernating it again. The resumed harness runs with the broker's environment (`swarm daemon` keeps `PATH`, `HOME`, the locale, `TMPDIR`, `USER`, `LOGNAME`, `XDG_CONFIG_HOME` and the relay's own variables), so a harness reads its credentials from its own configuration under `HOME`, never from the ref.

### Machine waker

- `swarm machine wake-url <url> [--secret-env VAR]` registers the machine's webhook (`machine_wake {url, secret}`), the secret read from `$VAR`, default `SCP_MACHINE_WAKE_SECRET`, never from argv; `swarm machine wake-url --clear` clears it.
- A hub embedded in a provider's platform takes an optional `machineWake(event)` function in place of registered URLs (the `RelayHub` dependency), called in-process for any machine with the payload object below and answering `{ok, status?, declined?}`; no url or secret is stored. Without it, the registered webhook is used.
- The `RelayHub` dependency `wake` overrides `HUB_WAKE`, how and how often the machine waker is called (SPEC section 19.3):

  | Option | Default | Meaning |
  | --- | --- | --- |
  | `crossAccount` | `false` | a message from another account's contact calls the waker too |
  | `senderAccountPerHour` | `6` | calls messages from one other account may cause, an hour |
  | `targetPerHour` | `12` | calls messages from other accounts may cause for one endpoint, an hour |
  | `debounceS` | `60` | the least time between two calls for one machine |
  | `backoffMaxS` | `900` | the longest backoff after failed calls |
  | `wakingTimeoutS` | `debounceS` | how long a row shows `waking` after an accepted call before it falls back to hibernated |
  | `declineHoldS` | `Infinity` | how long a decline holds calls for its message and sender; `Infinity` until it clears as SPEC section 19.3 says |

  The per-hour caps and `declineHoldS` take `Infinity`; the durations are finite. `RelayHub.clearWakeHold(machine?)` forgets a machine's (or every machine's) decline, debounce and backoff, for example once the provider's budget allows wakes again; that, or the alarm once a `declineHoldS` hold ran out and the debounce (at least a minute) since the declined call passed, calls the waker once for the oldest queued message a delivery would have called for. That wake charges the sender account's cap once per message and window (a mark row per queued seq in `wake_limits`), and when only a per-hour cap refused it, the alarm retries the machine once the window resets and the debounce since its last call (at least a minute) passed (`machine_wakes.retry_at`, cleared by an accepted call). The holds and the per-hour counts live in the hub's SQL storage (tables `machine_wakes` and `wake_limits`), and a row's waking deadline in its endpoint row.

### Runners

A runner starts a spawn on another machine of the same account (`swarm spawn --via <runner> [--name N] [--title T] [--labels K=V,...] -- <harness command>`). The runner is the whole spawn hand-off: it picks or boots the machine, provisions the harness's credentials there and starts the harness. The worker shows under its spawner through a cross-machine link (SPEC section 6.12) and hands its result back as a message.

- **Configuration.** `~/.config/scp/runners.json`, beside the policy file (`$SCP_RUNNERS_FILE` overrides the path), maps runner names to `{command: [argv...], timeout_s?}`; other fields are ignored. A name matches `^[a-z0-9][a-z0-9-]{0,31}$`; `command` is a non-empty list of words, run without a shell; `timeout_s` is whole seconds, 1 to 3600, default 60. An unknown runner is a usage error naming the configured ones.
- **Spawner.** `--via` mints no grant and runs no local harness. It takes `--name`, `--title`, `--labels` and `--json` (and `--as` or `--harness` for the spawner's identity, and `--bg`); any other flag is a usage error. The spawner has to be an agent of a broker linked to a hub (SPEC section 14.2), since a session on another machine shows under it only through the hub; otherwise it is a usage error. Name and title default as in "Spawn launcher".
- **Request.** The runner's `command` starts detached, with the spawner's environment minus its identity variables ("Spawn launcher"), and reads one JSON line on stdin, then end of input: `{"scp": 1, "event": "spawn", "name", "title" (string or null), "cmd": [argv...], "cwd", "parent": "me/<machine>/<handle>", "labels": {...}, "join": {"under": <parent>, "name": <name>, "mode": "account"}}`. `parent` is the spawner's full address on its own machine. The request carries no grant, token or other credential.
- **Exit.** Exit 0 means the worker started: the spawn prints the worker's name and returns at once. On exit 0 the runner may print one JSON object on stdout, `{"address": "me/<machine>/<name>", "machine": "<machine>", "ref"?: "<opaque>", "attach"?: "<command line or URL>"}`. `ref` is at most 256 printable ASCII characters. `attach` opens the remote session's terminal (for example `ssh vm-3 -t tmux attach -t w1`): one line of at most 1024 characters without control characters. Either is dropped when it looks like a credential (SPEC section 7.8). The spawn shows `address` and `machine`, then `attach: <attach>` on its own line, and with `--json` prints `{name, runner, parent, address?, machine?, ref?, attach?}`. A reply that is absent, not that object, has a malformed field, or names another worker or machine is ignored. Any other exit fails the spawn with that code and the runner's cleaned, redacted stderr tail (at most 2048 bytes). A runner still running after `timeout_s` is sent `SIGTERM` (its process group) and the spawn fails with code 124. A runner that cannot start fails with 127 (not found) or 126.
- **Join mode `account`.** The machine the runner picks has to be logged in to the same account (its own device login or API token) with its broker bridged to the hub. The worker joins as a root of that machine's tree, linked under `join.under` by `parent_addr` (SPEC section 6.12).
- **Launcher.** On the remote machine the runner runs `swarm spawn --name <name> --under <parent> -- <cmd...>`. When `--under` names `me/<machine>/<handle>` of another machine than the broker's own (the broker has to be linked to a hub, else it is a usage error), spawn runs linked: it mints no grant (grants are per broker) and refuses scope and grant flags (`--scope`, `--rules`, `--in`, `--out`, `--allow`, `--group`, `--ttl`, `--peek`, `--no-push`), identity flags and `--via`. The worker's environment is the spawner's minus its identity variables, plus `SCP_NAME=<name>` and `SCP_UNDER=<parent>`, so the worker's own claims carry `under` (SPEC section 6.4). An address of the broker's own machine keeps the grant spawn of "Spawn launcher".
- **Handback.** When the harness exits, the spawn claims `<name>` exactly as the worker (the harness of the command, its `cwd`, `under` the parent), sends the parent one `message` whose body follows SPEC section 9.3 (`worker <address> finished (exit <code>)` and the cleaned output, or `worker <address> exited without replying (exit <code>)`, then the stderr part after a non-zero exit), and sets its status `done` with note `exited <code>`. A harness that never started hands nothing back.
- **Limits.** The handback is an ordinary cross-machine message from the worker, routed through the hub and subject to scope, judging and send limits: there is no steer key, stop push, `spawn_steers` resume or `steer not applied` lines, and no grant caps reach across machines. The worker is a root of its own machine linked by `parent_addr`, with the limits of a cross-machine link (SPEC section 6.12).
- **Deferred.** A hub-minted one-time join grant, bound to the account, the parent, the name and a TTL, so the remote machine needs no standing account login; optional runner `stop` and `status` commands (stdin `{"scp": 1, "event": "stop" or "status", "name", "address", "ref"?}`, status answering `{"state": "running" | "hibernated" | "gone"}`) and a CLI verb to call them; `via` on the MCP spawn tool; showing `attach` in `list` and `peek`, which needs it stored with the endpoint.

## Defaults

- The spawn grant TTL is 3600 s.
- A spawn holds a resumable worker for 1800 s without a message when detached or `--durable`, and not at all in the foreground unless `--idle` is given (`SPAWN_IDLE_S`). The built-in spawn waker times out after 60 s and its runner tries to take the endpoint back for 10 s.
- The push wait defaults to 600 s (0 to 3600).
- The hosted relay removes every cap, count, rate and size limit here (durations keep their defaults) and the judge's budgets and hold cap; its only throttle is Turnstile on the sign-in form that emails a code.

## CLI verbs

`swarm [--as <handle>] [--harness <h>] [--human] [--json] [--cloud] [--machine <name>] <verb>`

| Verb | Wire |
| --- | --- |
| `whoami` | `hello` |
| `list [--all] [--tree] [--roots]`, `list --contacts` | `list`, `list {contacts}` |
| `peers [--all] [--roots]` | `peers` |
| `send <to> [text or -] [--reply-to <ref>] [--thread <t>] [--private] [--attach <path>]... [--live]` | `message`, `send` |
| `reply <ref> [text or -]` | `message {member}`, `send {kind: reply}` |
| `ask <to> [text or -] [--wait <s>] [--require-reply] [--thread <t>] [--private] [--attach <path>]...` | `ask` |
| `ask-wait <id> [--wait <s>]` | `ask_wait` |
| `inbox [--wait <s>] [--keep]` | `inbox`, `ack` |
| `ack <ref>... or --all` | `message`, `ack`; `--all` pages `inbox` |
| `show <ref>` | `message`; prints one delivered message whole, read state unchanged |
| `status [--all] [--activity]`, `status <id>`, `watch`, `log [--since <n>] [--limit <n>] [--scope or --judge or --peek]` | `snapshot`, `receipts`, `watch`, `log` |
| `set-status <state> [note]`, `set-now <text>`, `set-title <text>`, `event <kind> [--tool <t>] [--summary <s>]` | `status`, `status {note}`, `title`, `event` |
| `join [<handle>] [--under <parent>] [--harness <h>]`, `leave [--endpoint <ep>]`, `name [<handle>] [--endpoint <ep>] [--take-over]` | `hello {transient, claim, join}`, `leave`, `rename` (SPEC section 6.12); `name` alone prints the session's address |
| `adopt <child> [--parent <p>]`, `link accept <id>`, `unlink [<child>]` | `adopt`, `link_accept`, `unlink` (SPEC section 6.12) |
| `rules show or set or widen or lift [<endpoint>] [<cap>] [--in] [--out] [--scope] [--allow]`, `why <from> <to>` | `rules*`, `why` |
| `group create or add or rm or rules or ls or show or drop or pin or unpin [<name>] [<endpoint>] [--rules] [--in] [--out]` | `group` |
| `admin grant or revoke <endpoint> --tree <root> or --group <name>`, `admin ls` | `admin` |
| `spawn-env [--name] [--title] [--scope] [--rules] [--in] [--out] [--allow] [--group] [--under] [--ttl] [--peek local]` | `grant` |
| `spawn [spawn-env flags] [--labels <k=v,...>] [--handback-bytes <n>] [--no-push] [--bg or --fg] [--idle <s or Infinity>] [--durable] [--] <command...>` | `grant {spawn}`, `spawn_steers`, `spawn_exit`; holding a worker: `hello {claim}`, `wake {via: "spawn-resume", ref?}`, `inbox {offer}`, `injected`, `hibernate` ("Spawn launcher") |
| `end <worker>` | `spawn_end` (SPEC section 9.5): lets go of a worker a spawn holds or a durable spawn hibernated |
| `spawn-wake`, `spawn-wake --run` | none from the user: the broker's built-in `spawn` waker and its runner ("Wake hooks") |
| `spawn --via <runner> [--name] [--title] [--labels <k=v,...>] [--json] [--] <command...>` | `cloud_status`, then the runner ("Runners") |
| `spawn --under me/<machine>/<handle> [--name] [--title] [--labels] [--handback-bytes <n>] [--bg or --fg] [--] <command...>` | `cloud_status`, then `hello {claim {under}}`, `send`, `status` as the worker ("Runners") |
| `peek <endpoint> [--turns <n>] [--full]`, `peek --who [<endpoint>]`, `set-peek on or local or off [<endpoint>]` | `peek`, `peek_who`, `peek_set` |
| `holds [ls [--all]]`, `holds show <id>`, `holds release <id> [--redacted]`, `holds refuse <id> --reason <text>` | `holds`, `hold_show`, `hold_release`, `hold_refuse` |
| `policy show or path or check <from> <to>` | `policy`, `hello` (path), `policy_check` |
| `daemon [--idle <d>] [--policy <file>]`, `daemon stop or restart` | runs the broker; stops it (SIGTERM to the lock's pid, checked against its command line) or replaces it |
| `relay use <url> or show`, `login [--token <t>] [--label <name>] [--no-browser]`, `logout` | local configuration; `login` checks a token with the hub's `GET /v1/whoami` or runs the device flow (`/v1/device/code`, `/v1/device/token`), then `bridge_relink` to a running broker |
| `cloud enable [--machine <name>] or disable or status`, `up [--token <t>] [--label <name>] [--machine <name>] [--no-browser]` | bridge configuration; `bridge_relink` after `enable`; `cloud_status`; `up` logs in if needed, runs `cloud enable` and `setup --write`, then `bridge_relink` |
| `machines [rm <name>]` | hub `machines`, `machines_rm` |
| `hibernate --wake <provider> [--ref <ref>] [--eta <s>] [<endpoint>]`, `hibernate --all --wake <provider> [--ref <ref>] [--eta <s>]`, `hibernate --wake none or --gone [<endpoint>]` | `hibernate` (SPEC section 19.1); prints each endpoint and its presence, and whether the hub has it |
| `machine wake-url <url> [--secret-env <var>]`, `machine wake-url --clear` | `machine_wake` (SPEC section 19.3); the secret comes from `$<var>` (default `SCP_MACHINE_WAKE_SECRET`) |
| `mesh <verb>` | SPEC section 14.8 (experimental) |
| `mcp [--as <h>] [--harness <h>] [--channels]`, `mcp install --harness <h> [--as <h>]` | the MCP shim (see "MCP server"); `install` prints the mount command |
| `hooks install --harness <h> [--scope project or user or local] [--write or --print] [--block-stop-on-unread] [--push-wait <s>]`, `hook <harness> [event]` | see "Harness bindings" |
| `setup [--write] [--undo] [--no-hooks] [--block-stop-on-unread] [--join auto or manual] [--bin <path>] [--harness <h>]` | see "MCP server" and "Harness bindings" |
| `claude [--name N or --as N] [--root] [--] [claude args...]`, `codex [--name N or --as N] [--root] [--headless] [--resume <thread>] [--] [codex args...]`, `pi [--name N or --as N] [--root] [--] [pi args...]`, `opencode [--name N or --as N] [--root] [--] [opencode args...]`, `agy [--name N or --as N] [--root] [--] [agy args...]`, `grok [--name N or --as N] [--root] [--] [grok args...]`, `muse [--name N or --as N] [--root] [--resume <session>] [--approval-mode <mode>] [--] [muse serve args...]`, `t3code [--name N or --as N] [--root] --thread <thread> [--base-dir <dir>]` | launchers (see "Harness bindings"; "Launcher attribution") |
| `help [<verb>] [--all]`, `<verb> --help`, `version` or `--version` | none; `version` prints the installed package's version, read from its `package.json` at run time |

A launcher consumes only its own flags and passes every other argument to the harness: `claude`, `pi`, `agy` and `grok` read a leading `--name`, `--as` or `--root`; `codex` reads `--name`, `--as`, `--root`, `--headless` and `--resume`, `opencode` reads `--name`, `--as` and `--root`, and `muse` reads `--name`, `--as`, `--root`, `--resume` and `--approval-mode`, anywhere before a `--`; `t3code` takes only `--name`, `--as`, `--root`, `--thread` and `--base-dir`, and refuses any other argument. Each launcher always wakes its session ("Harness bindings"). A command whose stdout reader goes away (`swarm status | head -1`) exits 0 quietly.

## MCP server

The MCP server speaks JSON-RPC 2.0 over stdio (lines up to 4 MiB; protocol versions 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05), binds with `hello` (resuming with its token after a reconnect), sets status `working`, sends `wake {via}` once `initialize` shows whether the client takes channel pushes (SPEC section 11.1), and sends `whoami` every 30 s. Errors are returned as tool results marked `isError` with text `<code>: <message>`.

| Tool | Parameters | Wire |
| --- | --- | --- |
| `relay_whoami` | | `whoami` |
| `relay_list` | `all?`, `contacts?` | `list` |
| `relay_peers` | `all?` | `peers` |
| `relay_send` | `to` (1 to 16), `body`, `thread?`, `private?`, `live?` | `send` with a `client_id` |
| `relay_link_accept` | `id` | `link_accept`, accept a link request (an adopt of a child under another parent, SPEC section 6.12) and sit under its sender as a worker |
| `relay_join` | `name?`, `under?` | `hello {transient, claim, join}` on a fresh connection, then the shim's own claim |
| `relay_leave` | | `leave`; the shim then claims nothing until a join |
| `relay_status` | `id` (id, short id or `last`) | `receipts` |
| `relay_spawn` | `command` (argv, at least one word) or `harness` (`claude`, `codex`) with `task` and `model?`; `name?`, `title?` (at most 64), `no_push?`, `handback_bytes?` (1 to `LIMITS.handbackBytes`), `cwd?` (absolute, an existing directory) | `whoami`, then the spawn operation ("Spawn launcher") in the background, as the caller |
| `relay_reply` | `id` (id, short id, suffix of 4 or more, or `last`), `body` | `message {member}`, `send {kind: reply}` |
| `relay_inbox` | `wait_s` (0 to 50), `ack` (default true) | `inbox`, `ack` |
| `relay_ask` | `to`, `body`, `wait_s` (default 30), `require_reply?` | `ask` |
| `relay_ask_wait` | `id`, `wait_s` (default 30) | `ask_wait` |
| `relay_set_status` | `state`, `note?` | `status` |
| `relay_set_now` | `now` | `status {note}` |
| `relay_set_title` | `title` (at most 64) | `title` |
| `relay_rules` | `action` (`show`, `set`, `widen`, `lift`, `why`), `endpoint`, `in`, `out`, `scope`, `allow`, `cap`, `from`, `to` | `rules*`, `why` |
| `relay_group` | `action`, `name`, `endpoint`, `rules`, `in`, `out` | `group` |
| `relay_peek` | `endpoint`, `turns` (1 to 10), `full?`, `who?` | `peek`, `peek_who` |
| `relay_set_peek` | `on` | `peek_set` |

- `relay_spawn` is listed only when the server is given a way to spawn. Without `cwd` the worker runs in the caller's working directory when that exists. `harness` builds `claude -p <task>` or `codex exec <task>`, with `--model <model>` when given. It returns the worker's name and its runner's pid once its harness started; a failed spawn is a tool error carrying the spawn's message.
- `relay_send` adds a line pointing to `relay_status` with the message's short id when a recipient's state is `running` or `starting`.
- `relay_inbox` precedes a message with `shown_at` by the line `(a hook already showed you this at <HH:MM:SS>; that copy may reach you after this call)`.

With `--channels`, the server advertises the `claude/channel` experimental capability and pushes messages as `notifications/claude/channel` (SPEC section 8.4). It pushes only to a client that declares the capability, or to the Claude Code that `swarm claude [--name N or --as N] [--] [claude args...]` started: the launcher adds `--dangerously-load-development-channels server:scp` after the user's arguments (before any `--`), unless they already name `server:scp`, and runs claude with `SCP_CHANNELS=<its pid>` (and `SCP_NAME=N` when named); the server pushes when its harness's parent is that pid. The launcher refuses to start when Claude Code has no `scp` mount with `--channels` at user, local or project (`.mcp.json`) scope, or when `--strict-mcp-config` comes without `--mcp-config`; with `--mcp-config` it trusts that configuration and warns. Without `--name`, in auto join mode, and unless the arguments set a system prompt, it also passes `--append-system-prompt` asking the session to pick a name with `swarm name <name>`. Any other client keeps the hook path, since a push marks the delivery injected. `setup` mounts Claude Code's server with `--channels`.

## Harness bindings

All hooks run `swarm hook <harness> [event] [--block-stop-on-unread] [--push-wait <s>] [--no-inject] [--subagent] [--wake <s>]` with the harness's JSON payload on standard input (at most 8 MiB). The event is read from the payload (`hook_event_name` or `hookEventName`), else from the argument. With `--wake <s>` the hook is a waiter: it sends `wake {via: "opencode-plugin"}`, waits up to `<s>` seconds (at most `LIMITS.waitMaxS`) with `inject {wait_s}`, and prints what it is offered as a prompt-submit reply.

The Wake column is the `wake` value the endpoint advertises (SPEC section 11.1); with none, an idle session reads new messages at its next turn.

| Harness | Events | Context output | Stop output | Stop push | Subagents | Wake |
| --- | --- | --- | --- | --- | --- | --- |
| Claude Code | SessionStart, UserPromptSubmit, PostToolUse (`*`, plus a `--subagent` handler), Stop (`--push-wait 600`, timeout wait plus 30 s, `statusMessage`), SubagentStop (plus an async `--subagent` handler), SessionEnd | `{"hookSpecificOutput":{"hookEventName":<event>,"additionalContext":<text>}}` | Version 2.1.163 or later: the same with `"hookEventName":"Stop"`; earlier: `{"decision":"block","reason":<text>}` | yes | yes | `channels` under `swarm claude`; `claude-inbox` in a background session (`swarm claude --bg`); else none |
| Codex | SessionStart, UserPromptSubmit, PostToolUse, Stop (as Claude Code), SubagentStop (activity only), SessionEnd | as Claude Code | `{"decision":"block","reason":<text>}` | yes | no | `codex-app-server` under `swarm codex`; else none |
| Grok | Claude Code's hooks, labelled by environment | as Claude Code; injection after tools only | `{"decision":"block","reason":<text>}` | yes | no | `grok-resume` under `swarm grok`; else none |
| Muse | Plugin manifest: SessionStart, UserPromptSubmit, PostToolUse, Stop, SessionEnd | as Claude Code | `{"decision":"block","reason":<text>}` | yes | no | `muse-serve` under `swarm muse`; else none |
| T3 Code | Its provider's hooks (codex, Claude Code), labelled by environment and process | as the provider | as the provider | as the provider | as the provider | `t3code` under `swarm t3code`; else none |
| Gemini / Antigravity (agy) | SessionStart, PreInvocation (prompt), PostToolUse (activity only), Stop (only when the termination reason is `NO_TOOL_CALL`) | `{"injectSteps":[{"ephemeralMessage":<text>}]}`; `{}` when empty | `{"decision":"continue","reason":<text>}`; directives as a count and pointer | no | no | `agy-resume` under `swarm agy`; else none |
| pi | Extension: mounts `swarm mcp --harness pi --channels` and registers its tools as pi tools | none; messages arrive by channel push | none | no | no | `pi`, unless `SCP_PI=0` |
| OpenCode | Plugin: tool execution (text appended to the tool output), chat message (synthetic text part), session created, session idle, session status | as Claude Code; prompt shows an unread pointer only | Emulated: on idle, a held stop's text (`{"decision":"block","reason":<text>,"unread":<n>}`) is submitted as a new prompt | no | child sessions as activity | `opencode-plugin`, unless `SCP_WAKE=0`; always under `swarm opencode` |

Claude Code wakes only under `swarm claude [--name N | --as N] [claude args...]` (see "MCP server"): its MCP server, mounted with `--channels`, pushes each message as a channel notification and advertises wake `channels`. The launcher refuses to start when Claude Code has no `scp` mount with `--channels`, or with `--strict-mcp-config` and no `--mcp-config`. `swarm claude --bg [--name N] [claude args...]` starts a background session (`claude --bg`), whose environment is Claude Code's daemon's, not the launcher's, and which loads no channels: it passes `--mcp-config` with a file (mode 0600, under `$SCP_DIR/claude-bg/`) holding one server named `scp`, the user's mount with `--channels` (else the CLI's own `mcp --harness claude --channels`) with `SCP_NAME=N` (else `$SCP_NAME`), `$SCP_GRANT` (the grant "Launcher attribution" minted, else the caller's, else empty, so a grant in the daemon's environment never applies), and `SCP_LABELS`, `SCP_DIR` and `XDG_STATE_HOME` when set, in its `env`. That server replaces the user's `scp` mount for the session, so one shim runs, claims the name and the grant (run from an agent's shell, or under `env $(swarm spawn-env --name N)`, the session links under the caller, also when its hooks, which lack the name and the grant, claimed it first) and wakes it as `claude-inbox`. Claude Code also gets `--name N` and no development channels. The launcher prints the short id and the address, followed by `under <parent>` when it minted the grant, and links `<short id>.json` to the file. `swarm claude attach <id|name> [--name N]` runs `claude attach`, first renaming the session's endpoint to N (`swarm name --endpoint`) and the name in its file, which a resumed session claims. `swarm claude --resume <id>` of a live background session says it is already woken through claude-inbox and attaches to it. Any other Claude Code session reads new messages at its next turn.

Claude Code passes on at most 8000 characters and 200 lines of a hook's `additionalContext`, and 2000 characters and 20 lines of a `reason`, cutting longer text mid-line. The Claude Code hook fits its text within those caps itself: it cuts relay message bodies from the last one back, and ends each cut body with `[truncated, <n> more chars: swarm show <short id>]`, so the agent can read the whole message.

The pi extension (`@scp-dev/pi`, bundled in the CLI and run by `swarm pi [--name N | --as N]`, which starts `pi --extension <file>` with `SCP_HARNESS=pi`; `SCP_PI=0` turns it off) declares the `claude/channel` capability and hands each pushed message to pi as a custom message with `deliverAs: "steer"` and `triggerTurn: true`: it steers a running turn after its tool calls, and starts a turn on an idle session. A resumed session (`pi --continue`, `pi --session <id>`) receives the messages that arrived while it was closed. A new session in the same pi process starts its shim only once the previous one exited, and a shim that could not claim the endpoint is reported to the user. The reference MCP shim exits once its harness process is gone (it was reparented).

The OpenCode plugin wakes an idle session (wake binding `opencode-plugin`): while no root session of the process runs a turn, it keeps one `swarm hook opencode --wake 50` waiting and submits what it prints as a new prompt with `session.promptAsync`; a turn starting stops the waiter, and a waiter that printed has already confirmed its messages, so they are never injected again. `SCP_WAKE=0` turns it off.

`swarm codex [--name N | --as N] [--headless] [--resume <threadId>] [codex args...]` runs codex app-server on a private unix socket with the TUI attached via `--remote`, passing every other argument to the TUI and giving the app-server the configuration codex would resolve from them (the `--profile` file, `-c`, `--enable`, `--disable`, `--strict-config`, then `-m`, `-s`, `-a` and the bypass flag). The launcher's channel pushes each message, starting a turn on an idle top-level thread via `turn/start` or steering a running one via `turn/steer`. The channel confirms injection so turn-boundary hooks do not show the same message again. Approvals stay with the TUI. `--headless` runs an ambient worker without a TUI that owns one thread (`thread/start`, or `thread/resume` with `--resume <threadId>`), with approval policy `never`; it declines every approval request and prints the agent's messages to standard output. Without `--headless`, `--resume <threadId>` opens the TUI on `codex resume <threadId>`. The channel advertises wake `codex-app-server`; a message for a thread neither idle nor active stays queued and is retried every 2 s.

The codex launcher removes inherited `SCP_SESSION` and `SCP_PARENT`, preserves `SCP_GRANT`, and creates `SCP_SHIM_BINDING` ("Self-attestation of tool shells"). The app-server receives the grant and passes it to the mounted shim through `env_vars`; the shim spends it to take its parent and title. The channel binds with `hello {observe: true, self: [process], grant?, cwd}` instead of a second claim. With a grant, it retries `unauthorized` while the shim has not spent it, so it binds only when the grant authorizes that endpoint.

Muse loads hooks only from a plugin. `setup` writes the manifest to `~/.config/scp/muse-plugin/.muse-plugin/plugin.json`, then runs `muse plugins install <dir> --scope user` and `muse plugins approve scp`: Muse holds a new plugin's hooks for review, and a headless `muse exec` runs none of the held ones. Its MCP server goes under `mcpServers.scp` in `~/.config/muse/settings.json`. Muse passes plugin hooks a fixed environment with no `SCP_*` or `XDG_*` variables, so the hooks key the session by the harness process and reach the broker at its default path. `swarm spawn -- muse exec <task>` adds `--contributor-mode off` unless the command sets it, and refuses a mode other than `off` or `on`.

`swarm muse [--name N | --as N] [--resume <session>] [--approval-mode never|on-request|untrusted] [muse serve args...]` runs a headless Muse session that messages wake. Muse's TUI cannot attach to another process's session, and Muse wakes no model from a hook, so the launcher hosts the session itself: it runs `muse serve` (the Muse Session Protocol, JSON-RPC over stdio) with every other argument, adding `--contributor-mode off` unless they set a mode (refusing one other than `off` or `on`) and `--disable-request-user-input`. It starts a session in its working directory (`session/start`, approval mode `allowAll`, `onRequest` (the default) or `promptUnmatched` for the three values), or reopens one with `--resume` (`session/resume`). The launcher's channel pushes each message as `turn/start` with `ifBusy: "steer"`, which starts a turn on the idle session or steers the running one; one muse serve does not accept, it is retried every 2 s. The channel confirms injection, so the prompt hook does not show the same message again, and advertises wake `muse-serve`. It prints the agent's messages to standard output, declines every approval request the mode leaves to a human, and refuses other server requests. It refuses to start without an `scp` mount in Muse's settings, and exits non-zero when the session's shim has not joined within 30 s or muse serve exits.

Muse gives neither the shim nor the hooks the launcher's environment, and a session's own MCP server may not reuse the name `scp`. So the launcher writes the name (`--name`, else `SCP_NAME`) and `SCP_GRANT` to `<broker dir>/launch/muse-<pid>.json` (mode 0600), with the muse serve process key, and removes it when it exits; a Muse shim or hook whose environment sets neither `SCP_NAME` nor `SCP_GRANT` takes them from that file when its process key matches its harness process. `swarm spawn` writes the same file for each muse run it starts, with the run's `SCP_NAME` and `SCP_GRANT` and, on a resumed run, its `SCP_SESSION` as `session` and the process key of `SCP_HARNESS_PID` as `launcher`; a shim or hook that takes them claims with session key `env:<session>` and that launcher's process, as a resumed codex run's do from its environment, so it joins the held worker. Under `env $(swarm spawn-env --name N)` the session claims N and links under the caller. The broker is the default one under `HOME`, the one Muse's shim and hooks reach; with `SCP_DIR` or `XDG_STATE_HOME` set elsewhere the launcher says so. The channel binds as `swarm codex`'s does: `hello {observe: true, self: [process], grant?, cwd}` once the shim claimed the muse serve process.

agy reads hooks from `~/.gemini/config/hooks.json` (user) or `<repo>/.agents/hooks.json` (project) and MCP servers from `~/.gemini/config/mcp_config.json` or `<repo>/.agents/mcp_config.json`. A headless `agy -p <task>` denies MCP tools without `mcp(scp/<tool>)` rules in `permissions.allow` of `~/.gemini/antigravity-cli/settings.json`, which `setup` writes. The installed hooks name their harness in the argument (`swarm hook agy <event>`, `swarm hook muse <event>`). Without `--harness` or `$SCP_HARNESS`, the CLI and the MCP server detect agy from `ANTIGRAVITY_AGENT=1` or `ANTIGRAVITY_CONVERSATION_ID`, and Muse from `MUSE_SESSION_ID`.

`swarm agy [--name N | --as N] [--] [agy args...]` and `swarm grok [--name N | --as N] [--] [grok args...]` run the harness headless, one process per turn, and wake it by resuming its conversation. The launcher holds the endpoint itself: it claims it with a fresh `SCP_SESSION` (removing inherited `SCP_SESSION` and `SCP_PARENT`, keeping `SCP_GRANT`), `SCP_HARNESS` set to the harness and its own pid as `SCP_HARNESS_PID` ("Process keys and sessions"), runs the reference MCP server in process with channels and advertises `agy-resume` or `grok-resume`. Before the first turn it asks `relay_whoami` and runs each turn with `SCP_NAME` set to the handle it holds, so the turn's MCP server rejoins the endpoint and its hooks join the session. Turns run one at a time with standard input closed: a prompt flag in the arguments (agy `-p`, `--print`, `--prompt`; grok `-p`, `--single`, `--prompt-file`, `--prompt-json`) makes the first turn the arguments as given; without one the launcher waits, and the first message opens the conversation. Each later turn is the arguments without prompt and conversation flags, plus `--conversation <id> -p <text>` (agy) or `--resume <id> -p <text>` (grok), where the text is the messages pushed since the last turn started, each framed as a channel push (SPEC section 8.4), joined by a blank line. Grok's id is the user's `-r`/`--resume` or `-s`/`--session-id`, else a new UUID the first turn passes as `-s <uuid>`; the launcher also adds the `--allow` rules for the relay messaging tools unless the arguments hold `--allow`, `--allowedTools`, `--allowed-tools`, `--always-approve` or `--permission-mode`. agy cannot be given an id for a new conversation (it starts one under a fresh id when the named one is unknown), so the launcher learns it: it names a file in `SCP_AGY_CONVERSATION_FILE`, and the first agy hook to see a root conversation (a payload `conversationId`, else `ANTIGRAVITY_CONVERSATION_ID`, with no `parentConversationId`) creates that file with the id; it is never overwritten. The launcher resumes the recorded id, else the user's `--conversation`; with neither after a turn, it exits 1 saying the hooks did not report it. `swarm agy` refuses to start without the `scp` hook set holding `SessionStart` in the user's or the project's agy hooks, and warns when the permission rules for a headless agy are missing. Both refuse flags that open an interactive or ambiguous session (agy `-i`, `--prompt-interactive`, `-c`, `--continue`, `--input-format`, `--remote-control` and `remote-control`; grok `-c`, `--continue`, `--fork-session`): nothing local starts a turn in an interactive agy or grok session, so an interactive one is not woken and reads messages through its hooks. A turn that exits non-zero ends the launcher with its code; the messages it carried were marked injected, not read, so they stay unread in the inbox. A signal is passed to the running turn and ends the launcher. The channel confirms each message once, and a message an agy hook showed before a model call is not offered to the channel again (SPEC section 8.4), so no message is delivered twice; a message that arrives during a turn waits for the next one. `$SCP_AGY_BIN` and `$SCP_GROK_BIN` pick the binaries (default on `PATH`).

T3 Code runs no agent loop of its own: its server drives a provider (one shared `codex app-server` per codex instance, Claude Code through its agent SDK, and others), which loads its own MCP servers and hooks, so a codex or Claude Code session in T3 Code already has the `scp` mount and the hooks `setup` installs; `setup` reports T3 Code when it finds it and writes nothing for it. Its sessions claim harness `t3code`: a hook or MCP server whose harness is codex or Claude Code takes `t3code` when its environment sets `CODEX_INTERNAL_ORIGINATOR_OVERRIDE=T3 Code` (T3 Code sets it for the codex app-server it starts, and codex hands it to hooks, not to MCP servers) or, unless `SCP_HARNESS_DETECT=0`, when the harness process's parent is a T3 Code server (the `t3` CLI, its native build, or the desktop app). Hook output keeps the provider's format. `swarm spawn` removes `CODEX_INTERNAL_ORIGINATOR_OVERRIDE` from a worker's environment.

`swarm t3code [--name N | --as N] --thread <thread> [--base-dir <dir>]` holds an endpoint for one T3 Code thread and wakes it. It finds the server in `<dir>/userdata/server-runtime.json` (`<dir>`: `--base-dir`, else `$T3CODE_HOME`, else `~/.t3`; a file whose `pid` is not running counts as none) or at `$SCP_T3_URL`, and takes the token `$SCP_T3_TOKEN`, else one `t3 auth session issue --base-dir <dir> --scope orchestration:read --scope orchestration:operate --ttl 30d --token-only` prints (`$SCP_T3_BIN` picks `t3`). It connects to `GET /ws?orchestrationProtocol=2` with `Authorization: Bearer <token>` and speaks Effect RPC with JSON framing: `{"_tag":"Request","id","tag","payload","headers":[]}` answered by `{"_tag":"Exit","requestId","exit"}`, stream values in `Chunk` frames, each acknowledged with `Ack`, and `Ping` answered with `Pong`. It checks the thread with `orchestration.getThreadProjection` and follows its active run with `orchestration.subscribeShell`. Like the agy and grok launchers it holds the endpoint itself (a fresh `SCP_SESSION`, harness `t3code`, `SCP_GRANT` kept) and runs the reference MCP server in process with channels, advertising `t3code`. Each batch of pushed messages, joined by a blank line, goes to the thread through `orchestration.dispatchCommand` as `message.dispatch` (`createdBy` `user`, `creationSource` `server`, `deliveryIntent` `auto`) with `dispatchMode` `start_immediately` when the thread has no active run and `steer_active` with that run otherwise; one T3 Code refuses is retried every 2 s. The channel confirms injection, so the hooks do not show the same message again. It refuses to start without a server, a token the server accepts or the thread, and exits non-zero when the connection or the shell stream ends or T3 Code deletes the thread. The thread's own agent joins through its provider's mount as a separate `t3code` endpoint, since T3 Code starts the provider and the launcher cannot name it.

Each integration registers the MCP server under the name `scp`: `setup` mounts `swarm mcp --harness <h>` (with `--channels` for Claude Code) in the MCP configuration of claude, codex, opencode, grok, muse and agy, and the pi extension starts `swarm mcp --harness pi --channels [--as <name>]` itself. Messages a hook shows are acknowledged on every harness except agy, where they are marked injected only. Channel pushes (Claude Code under `swarm claude`, pi, `swarm codex`, `swarm muse`, `swarm agy`, `swarm grok`, `swarm t3code`) mark them injected and do not acknowledge (SPEC section 8.4).
