Get started
Markdownllms.txt

Control: the channel back

On this page

This document is part of the sessionpipe protocol and is licensed under CC BY 4.0. The words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are used as in RFC 2119. Status: Draft (see VERSIONING.md); implemented in M6.

Events flow from the machine to the receiver. Control flows the other way: from a page the person has open (on a phone, say) to a coding session on one of their computers — a message for the agent, an answer to a permission prompt, a request to stop, a new session. Everything here is delivered through each harness's documented hook answers and input APIs; nothing is ever done to a process behind the harness's back.

1. Who does what

Role Holds
Device Signs every command A passkey or a device key (§2), and a day key the passkey or device key vouches for
Receiver Carries commands, queues them per machine A bearer token per machine; no key that can sign
Machine daemon Verifies, then delivers The keys enrolled at its own terminal
Session Receives the message —

A receiver decides whether to send. Only the machine decides whether to run. A receiver can queue a command, refuse one, or add checks of its own (a second look, a rate limit); it can never forge one, because it never holds a key the machine trusts. A bearer token authenticates the transport only. It never authorizes a command, and a machine MUST NOT run anything on the strength of one.

One daemon runs per computer. It holds one long-poll to each receiver it is paired with, whatever the number of sessions; each command names its session.

2. Keys and enrollment

A machine trusts WebAuthn passkeys (COSE -7 ES256 P-256, or -257 RS256 with a modulus of at least 2048 bits) made for the receiver's rp id, which the receiver declares in its well-known file (control.signing.rp_id, HTTP.md §1). A machine also trusts device keys (below): keys a client app holds in hardware where it has no passkey to offer.

A key enters a machine's trust store only from that machine's own terminal:

  1. The person runs sessionpipe control pair <receiver>. The daemon sends POST {control}/pair (control-pair.json) with the sink token the person configured, and gets a machine id, a pairing code, a url and six check digits (control-pair-started.json).
  2. The terminal prints the url and the digits. The person opens the url, sees the same digits on the receiver's page, and confirms with a passkey: an assertion over sha256("sessionpipe.pair:" + machine + ":" + code).
  3. The daemon polls GET {control}/pair?code=… until it answers paired with the key and that assertion (control-paired.json). A code the receiver expires unapproved MAY be renewed from the same terminal: a new POST {control}/pair naming the machine it was handed (so the receiver keeps one row), new digits printed, for as long as the command runs. The expired code answers nothing; no code lives longer than the receiver gave it. The daemon MUST verify the assertion against the key it was handed before it trusts the key. A receiver that swaps in another key cannot produce the proof.
  4. The first pairing of a machine also hands over the machine's own bearer token, which the daemon uses for polls, acks and hellos and nothing else. A leaked hook or sink token then cannot read the machine's queue or ack a command away.

Open pairing. A receiver that declares control.open_pairing: true lets step 1 run with no sink token, so setting up a machine is one command and one approval: sessionpipe control pair spacesheep.dev. The request carries a poll_key, a random secret the daemon makes and keeps; every status poll presents it as its bearer, and the receiver keeps only a hash. Until a signed-in person opens the link the pairing belongs to no account; the first to open it claims it, and approving it with their passkey is the consent. The paired answer MAY also carry sink_token (a key for the events lane, so the receiver's session board shows the machine without a second step) and account (who approved, in the receiver's words). Whoever approves becomes the passkey the machine trusts, so a receiver SHOULD show on the approval page where the request came from, and the daemon MUST print account when it gets one. The link alone is not a credential: without the poll_key nobody is handed the machine's token.

The pairing request and every hello MAY carry the machine's allowed folders and its mode (safe: nothing that needs approval runs unattended; auto: the harness's auto mode; inherit: no permission flag, so each harness starts in its own default on the machine, as the person's own sessions there do — with one exception, §6 prompt, where a relaunch may say again the narrowing a session itself chose), so a receiver can offer a folder picker for a new session, and files: true when the machine takes attachments on a start (§6), and prompt_files: true when it takes them on a prompt too. They are informational: the machine checks its own list again (§5 step 6). The mode, like the folders, is the person's choice at the machine's own terminal, and no command changes it. In inherit a turn may run with permission checks off, if that is what the harness's own settings say, so a machine MUST NOT run in inherit unless the person chose it there. A receiver that predates inherit reads it as an unknown mode and is likely to show the machine as safe, so the person would approve, and later send to, a machine described as safer than it runs. A receiver that shows inherit as what it is says so with control.inherit: true in its well-known file (HTTP.md §1). A daemon MUST NOT pair in inherit mode with a receiver whose well-known file does not say control.inherit: true, and MUST NOT switch to inherit while it is paired with one; it says why at the terminal and keeps the mode it had. A receiver that sets control.inherit MUST accept mode: "inherit" in a pairing request and a hello. A machine that does not say files: true may predate attachments and start the session without them, so a receiver SHOULD NOT send it a start with files; one that does not say prompt_files: true refuses a prompt with files (bad_fields), so a receiver SHOULD NOT send it one.

They MAY also carry handoff: true, when the machine takes reply_to on a prompt or a start (§6, Handoff), and ask, whether sessions on this machine may ask another agent through its daemon (§13). Both are informational. A machine that does not say handoff: true predates it and refuses a command with reply_to (bad_fields), so a receiver MUST NOT send it one. ask is the person's switch at the machine (sessionpipe ask on|off); a receiver can neither start an ask nor change it, and only shows what the machine said. A receiver that sees neither field treats the machine as one that predates both.

And they MAY carry start: for each harness in harnesses that takes options on a start, what it takes (control-hello.json):

"start": {
  "claude-code": {"permissions":["read","safe","edits","auto"],"permission":"auto","any_model":true},
  "codex":       {"permissions":["read","safe","auto"],"permission":"auto","any_model":true,
                  "models":["gpt-6-astra","gpt-5.6-sol"]}
}

permissions are the levels (§3.1) this machine will start that harness at, already capped by its mode, and permission is the one a start gets when it names none (one of permissions, or inherit on an inherit machine: no permission flag at all). models are model ids the harness offers there (at most 50), a suggestion and not a promise. any_model: true means any well-formed model is passed to the harness, which may still reject it (§10, failed model). A harness with neither models nor any_model takes no model. A harness with no entry, or a hello with no start, takes neither field: a sender MUST NOT put model or permission on a start the last hello didn't advertise them for, and a receiver SHOULD NOT queue one. (A daemon that predates them ignores both, and would run at its own mode.) A receiver drops an entry for a harness that isn't in harnesses. The daemon works the object out again for every hello, from what each harness's binary says it supports.

A machine that sends start, even an empty one, also takes a start whose cwd is ~ or ~/… (§3.1). One that doesn't send it predates that and refuses such a start (bad_fields), so a receiver SHOULD send it an absolute cwd, such as one from folders. No hello names the machine's home folder: the daemon reads ~ itself.

They MAY also carry accounts: the harness accounts signed in on the machine, each {harness, id, email}, where id is what events carry as session.account_id and email is the address the account signs in with, so the receiver's board can name an account rather than show its id. An account with no email MAY carry name instead: a Claude Code home that runs on another API (no claude.ai login; its settings set ANTHROPIC_BASE_URL) is named by that endpoint's host, {harness, id: "api-…", name: "api.z.ai"}. The email and the name travel only here, to a receiver the person paired with, and never on an event (events reach every sink, a team board included). The daemon reads the accounts again for every hello, so a new sign-in is named without pairing again. A receiver MUST NOT treat them as anything but a label: nothing on the machine is checked against them.

An account MAY also carry signed_in (boolean): whether the harness's command line is signed in to it as the daemon sees it (claude auth status for Claude Code, asked at most every ten minutes, again 30 s after a no). A session the Claude app signed in through the app runs fine in its window while the command line the daemon resumes with has no login of its own; without this a receiver learned that from the first message's failed ack ("run claude auth login", §10). With signed_in: false a receiver SHOULD say so on the machine or the session before a message is sent, naming that command and the machine. Absent when the daemon can't tell (no claude found, no answer) or predates the field; a receiver treats absent as unknown, never as signed out.

A hello MAY carry pool: the machine's account pool (§6 pool), several accounts of one harness behind the daemon so a session keeps going when one account's limit is reached (control-hello.json):

"pool": {"on": true, "harness": "claude-code", "accounts": [
  {"id": "b3f0c1d2-…", "email": "a@b.c", "label": "Fiction", "enabled": true, "status": "ready", "current": true},
  {"id": "7a1e2f3b-…", "email": "m@b.c", "enabled": true, "status": "limited", "until": "2026-10-29T10:00:00.000Z",
   "limited_by": "seven_day", "windows": [{"id": "five_hour", "used_percent": 12, "resets_at": "2026-10-29T08:00:00.000Z"},
                                          {"id": "seven_day", "used_percent": 100, "resets_at": "2026-10-29T10:00:00.000Z"}]}
]}

A daemon that knows the pool sends pool in every hello, whether or not the pool is on (on), and with on: false and accounts: [] when nothing is set up, so a receiver tells "no pool" from "an old daemon". accounts (at most 20) are in the machine's order: each one's id (the same string session.account_id and limits.observed.account_id carry for it, so a receiver joins the three without a map), email or label where known (they travel only here, as accounts above), enabled, a status — ready (has room), limited (used up until until, where the machine knows when), error (needs the person: sign in again), disabled (turned off) — current: true on the one serving new sessions now, and MAY carry windows, the account's freshest numbers in the shape of limits.observed.windows (1–8; from the idle usage poll when no session asked recently, since an idle account's numbers ride on no session and would reach no receiver otherwise), and, when the status is limited or error, limited_by: the id of the window that took the account out (seven_day), or rejected (the vendor's status) or error (sign in again) for the causes that are no window. A receiver reads windows as it reads limits.observed (a window whose resets_at has passed has reset) and MUST NOT send it on as an event: it is a hello's reading, for the receiver's own page. A daemon MUST send a hello at once when the pool changes (on or off, an account added, removed, reordered, enabled or disabled, a status change), as it does for its folders, so the receiver's page follows the machine rather than its own guess. A hello without pool is a daemon that predates the pool: a receiver MUST NOT offer pool controls for it. Whether a receiver may ask anything of the pool is §6 pool; the hello is a reading, never a promise.

Keys are listed and removed one at a time on the machine (sessionpipe control keys, sessionpipe control keys remove <id>); sessionpipe control off removes the machine, its keys and its token, and tells the receiver (POST {control}/off). Adding another passkey is the same pairing, with the machine id in the request. A receiver MUST NOT be able to add, replace or remove a key on its own.

A trusted device vouches for a new one (key.add). Passkeys live in a device's own keychain, and a phone often has a different one from the laptop that paired, so a person away from the machine's terminal adds a device this way: a device the machine already trusts signs a command of kind key.add, session sessionpipe:keys, carrying the new key (key: id, alg, spki, and for a device key type, store and uv) and an optional name. The machine runs every check of §5 on it — the signature from a key it already holds, the age, the nonce — and then adds the key to its store and acks delivered. It first checks the key as it would an enrollment: an SPKI it can import for that alg, and for a device key every rule of Device keys below. A key that fails is bad_fields, and nothing is stored. The receiver only carries the command; it still can never add a key on its own. A receiver SHOULD make plain, while the person is at a device that can approve, that every device they will send from needs its own approval, and offer to add the next one right then.

Device keys. An app whose web view cannot run WebAuthn for the receiver's rp id (a desktop or phone app) holds a device key instead. It is a P-256 key that the platform makes in a hardware key store (Apple's Secure Enclave, for example), never lets out of it, and never uses without verifying the person (biometry or the device passcode) for each signature. A device key is not a passkey and does not stand in for one: its signatures carry no authenticator data and no client data, because no browser and no web origin is involved (§3.2).

A device key is written (control-key.json):

{"id":"<43 base64url characters>","alg":-7,"spki":"MFkw…","type":"device","store":"hardware","uv":"each-use"}
Field Rule
type "device". Absent, the key is a passkey and everything else in this document applies to it unchanged. Any other value is refused.
alg -7 (ES256, P-256).
spki The public key's SPKI, base64url.
id The SHA-256 of the spki bytes, base64url without padding: exactly 43 characters. A device key names itself, so its id can never stand for another key.
store "hardware": the private key was made in a hardware key store and never leaves it.
uv "each-use": the store will not sign without verifying the person, every time.

store and uv are the app's word, given when the key is made and approved by the person. A machine cannot prove them, just as it cannot prove the flags of a passkey made with attestation "none". A machine MUST refuse a device key with any other value of store or uv, or with any field not listed here. That way a kind added later is refused by every machine that predates it. A passkey carries none of type, store or uv. An app MUST NOT make a device key where it cannot meet both rules (an emulator, a computer with no hardware key store). There it holds no key, and it says so.

An app makes one device key per rp id, so no two receivers see the same public key.

A device key enters a machine's store the same two ways a passkey does: approving a pairing at the terminal's code (step 2, the proof being a device signature for purpose pair, §3.2), or a key.add from a device the machine already trusts. A machine that takes device keys lists them in key_types (["passkey","device"]) in its pairing request and in every hello. A receiver lists them in control.signing.key_types (HTTP.md §1) when it stores and carries them whole. An app MUST NOT offer a device key to a receiver, or for a machine, that does not list device. A machine that predates device keys would store one as a passkey and refuse every signature it makes. A receiver that predates them would drop the fields that make it one.

3. The signed command

3.1 The command string

The device writes the command as a JSON string, cmd, and signs those exact bytes (control-command.json):

{"v":1,"t":"sessionpipe.control","machine":"m_3Jq8…","kind":"prompt",
 "session":"claude-code:8c132906-8c3f-4814-870a-afc3d05e1d2e",
 "text":"Add the Oct 8 row","nonce":"pelZkFy2vRgrxlPqTKR6tA","iat":1790800000000}
Field Rule
v, t 1, "sessionpipe.control".
machine The machine's id: m_ then 16–40 base64url characters.
kind prompt · permission.answer · cancel · start · key.add (§2) · pool (§6).
session <harness>:<id>. The id follows its adapter's rule (ADAPTERS.md); for claude-code it is a UUID.
text prompt, start: the message for the model, 1 – 20 000 characters, not all whitespace (a real task brief runs to pages).
for, decision permission.answer only: the attention_id being answered, and allow or deny.
note Optional, ≤ 2 000 characters: shown to the model beside a decision or a cancel.
cwd start only: the folder the new session runs in: absolute, or ~ or ~/… (~\…), which the machine reads under the home of the user its daemon runs as. That is how events write a session's folder (PROTOCOL.md), so a receiver can start a session in another one's folder without ever knowing the machine's home; ~user and relative folders are bad_fields.
files start and prompt, optional (absent = no files; null or an empty list is malformed): 1 – 10 files the session should see, each {"name","type","size","sha256"} (below).
permission start only, optional: read, safe, edits or auto (below). Any other value is bad_fields.
model start only, optional: the model the harness should run, 1 – 100 characters matching ^[A-Za-z0-9][A-Za-z0-9._:/@\[\]-]*$ (never a leading -). The harness's own name for it; the machine passes it as one argument, never through a shell.
pool_action pool only, required: on · off · enable · disable · remove · order · refresh · login.begin · login.complete (§6 pool). Any other value is bad_fields: the list is closed, so an action added later is refused by every machine that predates it.
pool_account pool: an account's id as the hello lists it, or a pending sign-in's id, [A-Za-z0-9._:-]{1,128}. Required by enable, disable, remove and login.complete; optional on refresh; bad_fields on any other action.
pool_order pool, order only, required there: 1 – 20 account ids, no repeats.
pool_code pool, login.complete only, required there: what the person pasted back from the sign-in page (code#state), 1 – 512 characters, no line feed or carriage return.
reply_to start and prompt, optional: <harness>:<id> of a session on the same machine that gets the answer as a message once the command's turn ends (§6, Handoff). The id follows its adapter's rule, as session does. Never the command's own session. Anything else is bad_fields.
nonce 22 – 64 base64url characters (at least 16 random bytes).
iat Epoch milliseconds on the signing device's clock, an integer.

Each kind carries the fields of this table that name it, and v, t, machine, kind, session, nonce, iat and note; any other field (one this table doesn't list included) makes the command malformed (bad_fields): files or reply_to on any kind but start and prompt, permission or model on any kind but start, and a pool_* field on any kind but pool (or on a pool whose action doesn't take it), included. The whole string is at most 48 000 characters (the text, JSON-escaped, and the rest).

Permission levels. A start MAY say how much the new session may do without anyone approving it:

Level The session may, without asking
read Read and plan. Nothing that changes the folder or runs a command that could.
safe What the machine's safe mode allows for that harness (ADAPTERS.md): only what the folder's own settings already allow; anything that would ask is denied and named in the reply.
edits Also edit files inside cwd. A command that would ask is still denied.
auto What the harness's own automatic approval allows (Claude Code's auto mode, Codex's automatic review).

The machine's mode is the ceiling: a safe machine starts at read or safe, an auto machine at any of the four. An inherit machine's own default is the harness's, which sessionpipe cannot rank, so a start there may only name read or safe, and one that names none inherits. A start without permission gets the harness's default (§2, start), which is what the machine's mode means for it. inherit is never a command's permission: only the person at the machine chooses it. There is no level above auto: no command may turn a harness's approval off, and a daemon never writes a flag that does (bypassPermissions, --dangerously-*, danger-full-access, --yolo, --force). Wider access is the person's own choice at their own terminal, outside control.

Each entry of files (control-command.json):

"files":[{"name":"screenshot.png","type":"image/png","size":183402,"sha256":"<43 base64url characters>"}]
Field Rule
name 1 – 200 characters after Unicode NFC; no /, \, NUL or other control character (below U+0020, and U+007F); not . or .., and not starting with . (no dotfiles). The machine writes it under this name, NFC.
type 1 – 100 characters, a media type type/subtype, matching ^[a-z0-9][a-z0-9!#$&^_.+-]*/[a-z0-9][a-z0-9!#$&^_.+-]*$ without regard to case.
size The file's length in bytes, an integer, 1 – 26 214 400 (25 MiB).
sha256 SHA-256 of the bytes, base64url without padding: exactly 43 characters.

Together the sizes are at most 52 428 800 bytes (50 MiB), and no two names are equal after NFC, ignoring case. Any of these broken is bad_fields. The bytes are not in the command: the receiver holds them (HTTP.md §3), and because the hash is signed on the device, a receiver cannot swap a file.

3.2 Signatures

Either path is enough:

  • The day grant (the usual one). Once a day the person confirms with their key (a passkey or a device key), and the device makes a non-extractable P-256 day key. The person's key signs a grant: an assertion over sha256(str) where str is (control-grant.json) {"v":1,"t":"sessionpipe.grant","pub":"<day key SPKI, base64url>","iat":…,"exp":…,"rp":"<rp id>"}, with exp − iat at most 24 hours. Each command then carries csig, the day key's ECDSA P-256 / SHA-256 signature over the exact cmd bytes (raw r‖s, 64 bytes, base64url), and the grant (str plus the assertion).
  • A confirm. One assertion by the person's key (a passkey or a device key) over sha256(cmd) itself, for a command that should need a fresh fingerprint (a receiver's second look may ask for one).

Every passkey assertion MUST be a webauthn.get for the rp id, from an https origin on the rp id or a subdomain of it (plain http only when the rp id is localhost), not cross-origin, with the user-present and user-verified flags set. ES256 assertion signatures are strict DER.

A device signature stands where a passkey assertion stands (in a grant, a confirm or a pairing proof) when the key it names is a device key in the machine's store. It is {"cred": "<the key's id>", "sig": "<64 bytes r‖s, base64url>"}, plus str in a grant as before, and no other field. The signature is ECDSA P-256 / SHA-256, like csig, over the UTF-8 bytes of

sessionpipe.device.v1⏎<purpose>⏎<rp id>⏎<challenge>

where ⏎ is one line feed (U+000A) and there is none at the end. <purpose> is grant, confirm or pair. <rp id> is the receiver's rp id exactly as the machine recorded it at pairing. <challenge> is base64url without padding of the 32 bytes a passkey would sign for that purpose: sha256(str), sha256(cmd), or sha256("sessionpipe.pair:" + machine + ":" + code).

A machine decides how to verify a signature from its own record of the key, never from the signature. A device key's signature with ad or cd, a passkey's assertion without them, or a device signature that is not exactly 64 bytes is bad_assertion. The purpose line keeps a signature made for one use from answering for another. The rp id line keeps a signature made for one receiver from counting through another.

3.3 The receiver's envelope

A receiver carries each command in control.json:

{ "id": "01K6ZT…", "cmd": "{\"v\":1,…}", "csig": "b64c…", "grant": { "str": "{…}", "cred": "…", "ad": "…", "cd": "…", "sig": "…" },
  "at": "2026-10-08T09:14:02.110Z", "expires_at": "2026-10-09T09:14:02.110Z" }

id (a ULID) is the receiver's and is the ack key; it is not signed, so a machine deduplicates on the signed nonce, never on id. expires_at is at most 24 hours after at. The receiver MUST pass cmd through byte for byte.

4. Where the limits come from

The two implementations this was lifted from (spacesheep's server and CLI) disagreed; this spec fixes one value each: nonce 22–64 base64url characters (they had 16–64 and 22–128), text ≤ 4 000 characters and command ≤ 12 000 (they agreed on the text and capped the command at 12 000 and 16 KiB), session id per adapter (one accepted any id, the other only UUIDs). The caps were raised to 20 000 / 48 000 on 2026-10-01: a 4 000-character text box silently cut a real task brief in half.

5. What the machine checks, in order, before anything runs

  1. It parses the exact bytes it received and never re-serializes them.
  2. machine is this machine.
  3. The signature: a grant whose assertion (a passkey's, or a device key's signature, §3.2) verifies against a key enrolled on this machine, with the command's iat inside the grant's window (from iat − 5 min to exp), and csig verifying against the grant's day key; or a confirm (either kind) that verifies over sha256(cmd).
  4. The command was signed at most 24 hours ago and at most 5 minutes in the future. The machine judges by signing time, so a message sent while the lid was shut still runs when the machine wakes; expires_at bounds it too.
  5. The nonce is unseen for 25 hours. The machine records it before it runs the command, and a failure to record it refuses the command.
  6. The session's folder, read from the session's own transcript and never from the command, is on the machine's allowlist (for start, the cwd; for a session the machine itself started where the harness's transcript doesn't say, the folder that start ran in).
  7. permission.answer with allow needs for to name an attention that is open right now on that session (§7).
  8. For a start with permission: the level is one the machine advertises for that harness, which is never above its mode, or the command is refused permission. With model: the harness takes a model on this machine, and the id is on its list when the machine keeps a closed one, or the command is refused model. A machine never runs a start at another level or with another model than the one asked for.

A refusal is acked refused with a code: the verifier's (malformed, not_a_command, wrong_machine, unknown_kind, bad_session, bad_fields, bad_nonce, too_old, future, unsigned, untrusted_key, bad_assertion, bad_grant, outside_grant, bad_csig, replay, nonce_store) or the machine's (folder, no_attention, no_session, ambiguous_home, start_limit, copy_limit, permission, model, and for a pool command confirm_required, pool_account, pool_order, pool_off, §6). A daemon up to 0.9.0 sends mode where this document says permission.

The reference verifier is sessionpipe-core/control (WebCrypto only; the same file runs in a Worker, a browser and Node). A receiver SHOULD run it on arrival as an early word — against the account's keys, with a 10-minute age window — so a bad command is refused where the person can see why. That check is a courtesy; steps 1–8 on the machine are the gate.

6. Delivery

The daemon takes the first mode that fits, top to bottom, and acks which one it used:

Mode When Harnesses While waiting
sdk The daemon started the session itself (Agent SDK streaming input) and still holds it Claude Code 0 turns
waiter The session runs sessionpipe wait in the background, or was started with sessionpipe channel as its channel and is idle (§8) Claude Code 0 turns, no network
turn The session is mid-turn: the message lands at its next Stop Claude Code, Codex, Gemini CLI (AfterAgent), Cursor, Copilot CLI, Droid, Kiro 0 turns
inbox The session is live, idle and vetted, and the machine's permission class is the session's own: its local inbox takes the message (§6.3) Claude Code 0 turns
api The daemon holds the session through the harness's input API: an agent it started over the Agent Client Protocol (ACP) Any ACP agent installed on the machine (ADAPTERS.md, Agents driven through ACP); OpenCode's server and Codex app-server planned 0 turns
resume No live process holds the session Claude Code (claude -p --resume), Codex (codex exec resume), Antigravity (agy --conversation), an ACP agent that can reopen a session (session/load) cold start
fork Live and idle, with no mode above it Claude Code (--fork-session) cold start

Nothing fits: acked unsupported. Never two writers on one transcript: a session a live process holds is never resumed; it gets an in-place mode or a fork, and later messages to it go to the same copy.

What counts as held. A session is held when a live process of the harness has it, by the harness's own word: for Claude Code a row in its session registry (<config>/sessions/<pid>.json) that names the session, with its pid alive — a window the person has open, or another run of it. Where no row names it, it is still held while its transcript was written in the last 90 seconds by something the machine cannot account for: a registry is not the whole truth (a session can file its row under another id, a harness can have none), and a write that recent may have a writer behind it. A write the machine can account for does not hold anything: one made no later than the end of a run of the daemon's own, or no later than the session's own end as its hooks reported it (Claude Code and Codex: SessionEnd), since the process that made it has let go. A run that copies a session is a run in the copy: its end accounts for no write to the original and ends no turn there, so a copy that failed or was stopped leaves the original exactly as held, and as mid-turn, as it was. Where a run that was asked for a copy is not known to have run anywhere else — it named the original as itself, or named nothing — the Stop and the end its hooks reported may have been reported under the original's id, and they are that run's, not the original's: the original is held, as by a write of unknown hand, for 90 seconds after that run. So a session whose headless run exited seconds ago is resumed in place at once; it is not copied, and nothing is said about a window. A daemon MUST NOT resume inside that window on the strength of an absent registry row alone, and MUST NOT describe a session as open where a recent write is all it found.

Held is asked of the transcript the message would run in. For a session whose messages go to a copy that is the copy, and the copy's registry row and its hooks are kept under the copy's own id. A turn that the copy's own hooks report as running holds it like a process in the registry does, for as long as their word is good (ten minutes without an event, as for a cancel): a message that names the original MUST NOT be resumed in the copy in the middle of that turn. The copy is never itself copied, so the message is acked unsupported, with that reason. The ack is final: nothing is delivered when that turn ends, and the message is the person's to send again. Its detail SHOULD say so, since this is a hold that ends by itself: with the turn, or, where the process died without its hooks saying so, ten minutes after its last word. A machine that saw the process go (a run of its own) MUST NOT go on taking it at its word.

Held is asked when the run begins, not when the message was routed. A machine's own run in a session does not hold it against the next message: that message waits for the run and resumes the session after it, where a copy would set two agents on one task. This is the one case in which a resume is chosen while a process is in the transcript, so a daemon MUST look again when the waiting message's turn comes. Where something holds the session then — a window that opened it meanwhile — it MUST NOT be resumed: the message is routed afresh, as if it had just arrived (an in-place mode, a copy, or the reason neither is possible). A run that copies a session is not a run in the session it copies from and never stands for one. And a machine's record of its own runs is kept per session, harness and all: codex:<id> and claude-code:<id> are two sessions, and a run of the one says nothing about the other.

A session is the one its harness filed under that id, spelled as the harness spelled it. Everything that says a session is held is kept under its id: the registry row, the reports of its hooks, the machine's own record of its runs. A machine MUST find a session's transcript under exactly that name, and MUST NOT take what a file system that ignores case answers for another spelling of it: the transcript would be found, and nothing that says it is held would be. A command that names a session in another spelling is acked refused with no_session, as it is on a disk that tells the two apart. A start is the one place the other rule holds: on a disk that ignores case, a new session whose id differs from an existing session's only by case would be written into that session's file, so there such an id MUST be refused as taken (bad_session). A machine SHOULD refuse it on any disk; the reference daemon asks the disk for the id as given, in lower case and in upper case. A machine SHOULD NOT list every transcript it has to answer either question: asking for the path, and then reading the one folder that answered to see under which name, is enough.

Two rules hold across the in-place modes:

  • A message never chooses a permission mode. In an in-place mode no mode is picked at all: the turn is the session's own, in whatever mode the person left it in. A daemon MUST NOT change a session's permission mode in order to deliver a message, and MUST NOT declare to a session a permission class other than the machine's own.
  • The mode is chosen before the first byte, and one message is never delivered twice. Vetting and class resolution happen up front; once a byte has been written to a session the message is committed, whatever the session then does with it. A daemon MUST NOT fall through to another mode after writing, and MUST NOT retry. A second attempt needs a new signed command from the person, with a new nonce.

6.1 Homes

A session id can be filed in more than one of a harness's accounts (Claude Code: one projects/ tree per config dir). The machine resolves which account a message is for from the session itself: the one whose own live-session registry says a process holds it. Nothing live, or more than one live, is acked refused with code ambiguous_home, whose detail says which of the two it is, names the accounts that have the transcript and, where more than one is live, which ones — all by their directory name and never by path. A guess there put a message into an account that was signed out.

6.2 The session's mode

Some decisions below need the permission mode a live session is actually running in. A machine MUST read that from what the session itself reports — every Claude Code hook payload carries permission_mode — and MUST NOT infer it from the machine's own settings or from a flag it once passed. The reference daemon keeps the latest value per session in its own state, outside every harness account, under no other lock than its single event loop, and writes nothing when the value hasn't changed.

A mode can be changed at the keyboard without recording anything, so what is stored and what is true can disagree. A daemon MUST treat that as the ordinary case rather than as an error: the confirmation in §6.3 is what settles it.

6.3 inbox

Claude Code publishes a local inbox for each live session in its own session registry, and takes one JSON line on it: a user frame that becomes a real prompt in that process. The writer is the daemon's alone — it is not part of any library's public surface and no command line exposes it.

Before writing, a daemon MUST check all of:

  • the machine has such an inbox at all: Claude Code publishes none on Windows, where a message takes a cold mode instead. A daemon SHOULD say so before it reads anything else, since nothing it could read there would change the answer;
  • exactly one live registry record across the harness's accounts (two is a refusal, not a guess) for the session this would write to — which is the copy where the session the command named has one (§6 prompt), since that is where its messages go; the hold, the watcher and the confirming row are then the copy's too, in the account §6.1 resolved;
  • the record's inbox protocol is the one the daemon speaks;
  • the record says the live process is one of the harness's own interactive sessions — a registry record is a file any process of that uid can write;
  • the session's folder, resolved from the session itself as §5 step 6 requires, is on the machine's allowlist (which is checked before any mode is chosen, for every kind);
  • the socket is a socket, owned by the daemon's own uid;
  • the process is alive and its start time matches the record's, compared in one time zone — a registry that writes UTC and a ps that answers in local time disagree by hours and every delivery reads as a recycled pid;
  • the record says the session is idle (a session mid-turn takes turn instead);
  • the session's own permission mode (§6.2) resolves to a class this mode can name, and that class is the machine's own (below);
  • the command's signed iat is at most 120 s from now, in either direction — judged by the signed time and never by the envelope's at, which the receiver writes and does not sign, and as an absolute age because §5 step 4 admits a command dated up to five minutes ahead, which would otherwise stretch this window to seven;
  • and, where the inbox vets a sender by a key rather than by uid (everywhere but macOS), that the key is readable.

Any of these failing means the mode does not apply, and the message goes to whatever rung is next — which is a refusal to write, not a refusal to deliver. A receiver SHOULD set expires_at no further out than the freshness window for a session it expects to be reached this way. Every one of these checks, and every other reason not to write, is made BEFORE the mode is chosen, so that a reason found late cannot end a message on a mode it never used.

The class the machine declares is its own, never the target's. The sender states a permission class in the message; the receiving session takes one whose class matches its own and silently parks one that does not. A daemon that echoed the target's class back would leave that check with nothing to compare. The machine's class is bypass only where the person set the machine to name no mode at all (inherit, §2) and the resolved account's own default is a mode with permission prompts off and is allowed to be; every other machine is prompting. A daemon MUST NOT widen its own class to make a delivery fit, and MUST NOT use this mode where the two classes differ. Declaring buys delivery, never escalation: the receiving session still applies its own permission settings, and a peer can grant it nothing.

The message travels in the receiver's canonical envelope. A daemon MUST build it in the receiver's own attribute order and MUST confirm that what it built reads back as what it meant before sending — the receiver parses an envelope by rebuilding it, so anything that does not round-trip is parked unread rather than refused out loud. A body that would close the envelope early is a message this mode cannot carry, and that too is found before the mode is chosen. The envelope carries who the message is from, as the receiver's own host, cleaned to what can sit in an attribute and read as a name; it carries no claim the machine cannot vouch for. The frame is sent at the lowest priority the inbox offers, so the message becomes a turn of its own rather than joining one in flight, and it carries a mark identifying the message.

A busy window is asked twice. A window says that a turn is running in it in two places, and they are a moment apart at each end of the turn: its registry row says busy some hundreds of milliseconds before its hooks' report of the turn has reached the machine, and still says it for a moment after they have reported the turn's end. A message that lands in either moment finds a window that is busy, so its inbox is no way in, and not mid-turn, so its Stop is not waited for: routed on the spot it is answered in a copy, and every later message for the session then goes to that copy while the window sits idle. So where a window is busy by its row and not mid-turn by its hooks, and those hooks report to the machine at all, a daemon SHOULD wait a short while for them to say that a turn began (the reference daemon: two seconds, acking taken meanwhile) and then route the message again, once: turn if they have, the inbox if the window is idle after all, and otherwise what the ladder says. A cancel meanwhile ends the wait, and the message is acked as any stopped before it ran. The second look is taken only where this mode is one the machine has at all: on a platform where it cannot read a window's inbox (Windows), and for a window that offers none, the row is not consulted and such a message is answered in a copy as before.

A write is not delivery, and a turn is not proof by itself. The daemon MUST confirm within a short window (8 s here) that the session began a turn for this message — the row the harness writes for that turn says which message asked for it — and MUST NOT treat any other turn begun in that window as the confirmation: a message written and parked while the person typed something of their own would otherwise be acked delivered, and the answer to THEIR words sent back as the answer to this one. Unconfirmed, it is acked failed with code held and says plainly that the message was sent, may be held, and was not duplicated. It MUST NOT try another mode.

The session reads it as a teammate's request, not the person's. Claude Code tells the model that a message arriving this way came from another Claude session, not from its user, and to act on it within its own permission settings: never to edit those settings, CLAUDE.md or its configuration because one asked, and never to take one as the person's approval of a pending prompt (2.1.314; a live session asked this way to write CLAUDE.md declined, and said why). The words are still the person's, and the mode still reaches every live session — the Claude app's included — in its own thread and mode. A receiver SHOULD tell the person, with an answer acked mode: inbox, that the session took the message as a teammate's request. Where a session has a channel (§8), the message goes there instead, as the person's own words.

A message the session has not taken may still be released to it later, so for as long as that can happen (300 s, the receiving inbox's own hold) the mode stays shut for that session: a daemon MUST NOT write a second message into an inbox where one of its own is still unconfirmed, and MUST remember that across its own restart — an update restarts it, and the window outlives the process. Otherwise the obvious thing to do — send it again — puts two copies of the person's words in one place, and a released hold delivers both.

prompt

In the in-place modes the text reaches the session as a new message, framed so the model and the person can tell where it came from: Message from <person> via <receiver> (sessionpipe control): then the text. In turn mode the daemon's Stop hook answers {"decision":"block","reason":<framed text>}, and the harness continues the turn with it. A harness sets stop_hook_active on the Stop that follows; the hook blocks again only for another queued message, so a delivered message never loops. In resume and fork the ack carries the agent's reply (redacted by the secrets ruleset).

One at a time, in the order they were sent. A session takes one message at a time, and the messages that wait for it — behind a turn the machine is running, behind the session's own turn, behind each other — are delivered in the order they were signed (iat), and where two were signed in the same millisecond in the order they arrived. The machine takes a message's place in that order when the message arrives, before it verifies it: messages of one poll are verified side by side, and how long each verification, download or wait for the session's start takes MUST NOT decide which of them goes first. A message that turns out to have no delivery (refused, expired) gives its place up, and neither that, nor a message that fails, nor a cancel of the one that is running changes the order of the rest. A message that has its turn keeps it: one signed earlier that arrives after it has begun waits for it. In turn mode the order is the order the session's Stops are handed the messages in. A cancel and a permission.answer are not in line: they act at once. Each session has its own line, and nothing here orders one session's messages against another's — except that a session and its copy share a transcript: a message addressed to the copy by the copy's own id and one addressed to the session both run in the copy, and the machine MUST NOT run them there at the same time.

A prompt MAY carry files, to a machine whose hello says prompt_files: true. The machine fetches and checks them exactly as for a start (§6 start, Files, step 1), writes them to <cwd>/.sessionpipe/files/<session id>/<nonce>/<name> (the session's folder, and the command's signed nonce, which is unique and safe in a path where the receiver's message id is not signed), with the same rules (real directories, the message's folder new, each file created exclusively, 0600), and appends the same Attached files block to the text, with paths relative to cwd, inside the frame. All of it happens before the message is delivered, in every mode: for resume, fork and sdk the block is part of the message the run gets; for waiter and turn the files are on disk before the waiter or the Stop hook hands the text over. A file that fails is acked failed with code file, nothing written for it is kept, and the message is not delivered.

In the in-place modes (waiter, turn, inbox) the session answers in its own turn, so the daemon acks taken when it hands the message over and follows the session's own transcript until that turn ends; the final delivered ack carries the turn's text as its reply, so the person reads the answer where they asked. A turn that doesn't end within 30 minutes is acked delivered without one. The framed message tells the model its answer goes back by itself, so it never reaches for a command to send it (a real session invented one, 2026-10-01).

The reply is the answer to that message, or nothing. The message names itself — a mark in its frame (§6.3), or its id on a channel's tag (§8) — and the session's transcript records it in a row that carries the mark: the Stop hook's feedback in turn mode, the waiter's output where the session reads it, the inbox's or the channel's row. The reply is the text the session wrote after that row, up to the end of the turn it began: in turn mode the continuation the block caused, which ends at the session's next Stop. A daemon:

  • MUST read it from the transcript of the session it handed the message to. That is the session's own even where its other messages go to a copy (below): a message taken at a session's own Stop or by its own waiter is answered there, and what the copy writes meanwhile is another message's answer;
  • MUST NOT take anything the transcript gained before that row. A harness writes its rows a moment after the fact, so the last words of the turn that was ending arrive after its Stop hook has already asked; where the transcript ended at the hand-over is only where the search starts;
  • MUST NOT take what the session wrote for anything else: the next message handed over at the following Stop, a prompt typed after it, a turn a background task began;
  • where the row cannot be found, or the turn said nothing, sends no reply. The receiver has the session's transcript; a wrong answer under a person's message is worse than none.

The final ack is sent when that turn ends, which the session's own hooks report (its next Stop, a failed turn, the session's end), and not when the 30 minutes run out: those are for a turn that is still going.

The mark is made of the message's id, and only of a whole one. id is the receiver's and nobody signed it (§3.3), so a daemon takes nothing from it but what a ULID is made of: the mark is the word message and the last twelve characters of a 26-character ULID, and a channel's tag carries all twenty-six. An id that is anything else makes no mark, and a daemon MUST NOT put any of it into the text a session is handed — least of all into the header that says the message was signed. Such a message is still delivered, as far as it can be without one:

  • it is framed as a message with no mark;
  • it is not handed to a session's channel (§8) and not written into its inbox (§6.3): the mark is those two modes' only proof that the session took the message, so without one it takes the next rung instead (the machine's reason says so);
  • at a Stop or through a waiter it is handed over and acked delivered with no reply when the session's turn ends, since nothing the session writes can be put down to it.

A cold mode (resume, fork) re-asserts a narrowing the session chose, and names nothing else. The machine's mode is the person's own choice at that machine and the only thing that picks a permission flag (§2); inherit passes none, so the harness starts in its own default there. But no flag is not neutral: where an account's default is a mode with permission prompts off, no flag is the widest outcome there is, and a session the person had narrowed themselves would quietly come back wider. So for a session the machine did not start, on a machine that names no mode of its own, a daemon MAY pass the narrowing the session itself was last in, read from what the session reported (§6.2) and never from a flag it once asked for:

The session's own last mode The flag on a relaunch
plan that one: nothing the harness has is narrower
dontAsk, acceptEdits that one, where the account's own default is at least as wide; otherwise none
default, manual, auto, bypassPermissions, nothing recorded none

A daemon MUST NOT emit auto or bypassPermissions this way — the two values a setting can quietly turn into something else — and MUST NOT re-assert anything wider than what the machine itself does with no flag. "At least as wide" is read against the account's own default (Claude Code: permissions.defaultMode, user scope); a folder's settings or a managed policy may narrow that further, and they narrow what the re-asserted mode can do as well, so the comparison errs on the side of asking. A session the person put in Manual (default or manual) comes back at the account's own default, knowingly: a hook payload cannot tell a mode they chose from one nobody chose. A session the machine started keeps the options its start recorded (Options below), which this does not touch. Where a machine's mode asks for a mode the harness's own settings turn off (auto where auto is disabled), the turn still runs and the ack SHOULD say what it ran in instead and what would fix it. The ack's detail MAY name the mode the new turn's own record shows — which is what it ran in, rather than what was asked for.

A fork answers from a copy, not from the session the command named, and the person is owed that fact: the ack names the copy in session and the original in copy_of, and the message itself SHOULD tell the copy that it is one, so it does not carry on the original's work. They are owed the reason too, and the true one: the ack's detail opens ran in a copy, because and then says what held the session as the machine found it — it was open in a window, another process was running it, or its transcript had been written to in the last 90 seconds by a process the machine could not find — and, where the session's own inbox was looked at, why that was no way in. A later message's ack, which resumes the copy, opens ran in the copy made earlier, because with the same reason. A receiver SHOULD show the machine's reason and not one of its own: "open in a window" is one case of three. A copy is a leaf: later messages for that session resume the copy, and a copy is never itself copied. A machine MAY cap how many copies it makes in a day (each replays a whole conversation) and refuses beyond that copy_limit, with a reason. Where the harness has a contract for copying a session something else still holds — Claude Code's CLAUDE_CODE_RESUME_SOURCE_ALIVE — a daemon MUST use it, or the copy claims the original's transcript pointer and the delivered turn re-enters the person's own conversation unseen.

permission.answer

Delivered through the harness's permission hook answer (Claude Code PermissionRequest → hookSpecificOutput.decision.behavior; Codex PermissionRequest). The hook waits on the daemon's socket for a signed answer for up to its window (default 125 s) while the terminal shows its own prompt: whichever answers first wins. When the terminal answers first, the harness does not tell the hook; the daemon learns it from the first later hook event of that session that says this question is over (§7: its own thread's tool result; a Stop or a new prompt where the main thread asked; where a subagent asked, its own stop or a Stop with nothing left in flight), closes the attention, releases the waiting hook without an answer, and acks a later answer expired. Gemini CLI's Notification of type ToolPermission has no answer channel: acked unsupported. Nothing is ever auto-allowed: a daemon MUST NOT answer allow for an attention it did not receive a verified permission.answer for.

cancel

Stops the current turn where the machine has a way of its own to stop it:

  • A session the daemon holds, through that session's own interrupt: the Agent SDK's in sdk mode; ACP session/cancel for a session it holds in api mode (OpenCode /session/:id/abort and Codex app-server interrupt where those are wired). Acked delivered with that mode.

  • A turn the daemon is running as its own child process (a resume, a fork, a headless start): the daemon ends the child it spawned, the way it ends one at the 30-minute cap — SIGTERM to the child's own process group (the harness and the tools it started), then SIGKILL to whatever is left, ten seconds later or as the child exits, whichever is first; on Windows the child is killed. Once the child has exited nothing more is sent to that group: its number is no longer certain to be the daemon's own. The same holds for an agent the daemon holds (api mode) and closes: one that has already exited is sent no signal. The cancel is acked taken, then delivered once that process is gone, with the mode the turn was running in (resume or fork). The turn's own message still gets its one final ack: delivered, its reply what the turn had written, ending (The turn was cancelled.) as an ACP agent's cancelled turn does; a fork stopped after it named its copy still names it. A turn that ended by itself before the stop took effect leaves the cancel expired. A message waiting behind that turn is not cancelled: it runs next, in order, as it would have.

    The turn is the daemon's to stop from the moment its message's turn comes, not from the moment its process exists: a Stop that arrives while the daemon is still preparing it (checking the harness's login, fetching the message's files, waiting for a free slot) means it never starts. The cancel is acked the same way (mode only where the turn had got far enough to have one); a prompt whose turn never started is acked delivered with (The turn was cancelled.) as its whole reply, and a start that never began is acked failed with code cancelled, since no session was created and none must be shown as one. That ack is the command's one final outcome (§10) whatever it was in the middle of: a download in flight is dropped, a file that arrives all the same is not written, and a download that fails afterwards is not acked failed on top of it. For a session whose start is still running, the turn a cancel stops is that start, not a message waiting for it; a cancel never waits for a start to end.

    A cancel is for what was sent before it, and it may be through its own checks first: a machine takes the messages of one poll side by side, and a cancel has the least to check. Where nothing is in hand for the session yet, a daemon MUST wait for a start or a prompt for it that has already arrived, was signed no later than the cancel (iat), and is still in its own checks, until that one is in hand or is refused, and act then: a matter of milliseconds. Answered at once it said that nothing was running, or that there was no such session, and the turn it was pressed for then began. It MUST NOT wait for a command signed after it (the person stopped one thing and then sent another), nor for one that waits behind another turn: a message behind the message before it, a second start for a session behind the first. That wait is a turn long, and the turn is the one the cancel was pressed for.

    A turn that runs in a copy is that copy's turn as much as the session's: a cancel that names the copy — the one the session's messages go to, or the one a fork is making, once it has named itself — stops it exactly as one that names the session does. The ack and the copy's own events send a person to the copy, so that is where they press Stop, and the daemon MUST NOT describe its own child there as a process it did not start. It holds the other way round too — a cancel that names the session stops a turn running in its copy for a message that was addressed to the copy — and from the moment the turn is the daemon's to stop, not from the moment it runs. Where more than one turn is in hand under a session's two names, the one whose process is running is the one a cancel stops; a message waiting behind it is neither stopped nor what the cancel waits for.

A cancel names a session by its whole ref. codex:<id> and claude-code:<id> are two sessions: a cancel for the one stops no run of the other and interrupts no session the machine holds for the other, whatever their ids.

Never a signal to a pid the daemon did not start. A session open in a terminal or in the Claude app, and a headless run someone else started, belong to that process, and nothing here reaches into it: the cancel is acked unsupported, and its detail says who has the session in words the person can act on (press Esc in its window; stop it where it was started), which a receiver SHOULD show as they are. A Claude Code session in which nothing is running — no turn of the daemon's own, no live process in the harness's session registry, no turn its hooks reported — is acked expired: the turn ended before the Stop arrived. Any other harness's session the daemon is not itself running is unsupported.

start

A new session of the command's harness, in cwd, for any harness the machine can drive headlessly (it lists them in its hello's harnesses; any other is acked unsupported). The pairing request and the hello list at most 20: Claude Code, Codex and Antigravity first, then the ACP agents below in the registry's order, so a machine with more agents installed than that advertises the first 20 and still starts any of the rest a command names. Their modes include api whenever one of the listed harnesses is an ACP agent. Claude Code: with the command's session id, through the Agent SDK when the daemon has it, else claude -p --session-id. Codex: codex exec. Antigravity: agy -p. Acked delivered with mode sdk or resume, and the reply.

Any other agent, through ACP. An agent that speaks the Agent Client Protocol (ADAPTERS.md, Agents driven through ACP, names each one and its command) is driven the same way for every one of them: the daemon runs the agent's own CLI as a child process in cwd, with JSON-RPC over its stdin and stdout, and is its client: initialize (protocol version 1, no file-system or terminal capability, so the agent uses its own tools), session/new in cwd, then session/prompt with the framed text. The agent's agent_message_chunk updates are the reply as it grows (progress acks) and the session/prompt response ends the turn; end_turn and max_tokens are a delivered turn, refusal and cancelled say so in the reply, anything else is failed. Acked delivered with mode api. The agent names its own session (session/new → sessionId), so the start's id is its name everywhere, as above. The daemon keeps the agent running after the turn: a later prompt to that session goes to the same process (api, 0 turns), and a cancel is its session/cancel. An agent idle for ten minutes is closed; a prompt to a session no process holds reopens it with session/load when the agent said it could (agentCapabilities.loadSession) and is acked unsupported when it can't, naming the agent. The agent's session/request_permission is answered by the machine's mode and never by a receiver: safe picks a reject_once option, else reject_always, else answers cancelled, and the reply lists what was refused; auto picks allow_once, else allow_always, and answers cancelled when neither is offered; inherit answers as safe does, since the agent asked because its own settings want a person. The mode governs what the daemon answers, not what the agent asks: an agent that runs a tool without asking (OpenCode 2.1 wrote a file in safe mode with no session/request_permission) is bound only by its own settings, as it would be at its own terminal. Every other request an agent makes of its client (fs/*, terminal/*, elicitation/create) is answered method not found, since the daemon advertised none of them. The daemon only ever runs an agent that is already installed on the machine (found on PATH, or where SESSIONPIPE_ACP_<HARNESS> points): it never fetches one.

Some harnesses name their own sessions: neither codex exec nor agy can be told which id a new session gets. A machine whose harness names its own sessions keeps the command's id as the session's name in everything it reports: the start's acks carry no other session, a later prompt to that id resumes the harness's own session, and the hook events of that session are sent under the command's id. The reference daemon records the pair (and the folder the start ran in) the moment the harness reveals its id, in aliases.json beside its nonces; a hook event that the sender's worker read before that moment may still name the harness's id. A prompt for a session whose start the machine is still running waits for that start, even when the two arrive in the same poll, and then resumes it.

A receiver MAY name, in its well-known control.session_tools, MCP servers (mcp__<server>) that a session answering its message may use without asking: its own, usually, so that publishing the page it was asked for back to the receiver is not blocked by the machine's safe mode. A daemon passes them as allowed tools to every run it starts for that receiver, and MUST ignore any entry that is not an MCP server name — a receiver can never allow a built-in tool (a shell, a file write) on the machine.

Options. The machine turns a start's permission (or the harness's default) and model into the harness's own flags, and the table in ADAPTERS.md says which ones, per harness. It records both with the session, and every later run it starts of that session (a resume, a fork) is at the narrower of the recorded level and the machine's mode at that time, with the recorded model. On an inherit machine that is the narrower of the recorded level and safe, and a session that inherited (or recorded no level) runs at the machine's mode at that time. A session started at read stays read, and a machine turned down to safe takes every session down with it at its next run. A session the daemon holds (sdk) keeps its options while it is held. Its taken and delivered acks carry applied: what it ran with, {"permission":"edits","model":"sonnet"}, where permission is "inherit" for a start that inherited and model is there only when one was asked for. A receiver shows applied, never what was asked: a start that asked for options and came back without applied reached a machine that ignored them.

A start is a whole agent run on the person's computer, so the machine caps them itself, whatever a receiver sends: at most a few running at once and a few an hour (the reference daemon: 2 and 10, set in its control config), refused start_limit beyond that. A daemon SHOULD also cap the headless processes it runs at once and make the rest wait their turn rather than fail. A receiver SHOULD rate-limit starts before it queues them; that limit is its policy, the machine's is the gate.

Files. A start with files is handled only after every check in §5 passed and the nonce is recorded, and the same way for every harness the machine can start: the files land in cwd before the run, and nothing about them is the harness's. Before the session starts, the machine:

  1. Downloads each file (one after another is fine) with GET {control}/files/{sha256} and the machine's token. It MUST stop reading a response once it passes size bytes, and MUST NOT write a file whose length is not size or whose SHA-256 is not sha256.

  2. Writes the files to <cwd>/.sessionpipe/files/<session id>/<name> (the id the command names, which is the session's name everywhere, above), creating <cwd>/.sessionpipe/.gitignore with the single line * when it is absent (the folder ignores itself, so a repository never picks it up). It never follows a symlink: .sessionpipe, files and the session's folder MUST be real directories inside cwd (a symlink there refuses the files), the session's folder MUST be new, and each file is created exclusively (never over an existing path) with mode 0600.

  3. Starts the session with the command's text followed by the list of files, paths relative to cwd (so the model reads them without leaving the project):

    <text>
    
    Attached files (saved in this folder):
    - .sessionpipe/files/<session id>/screenshot.png (image/png, 179 KB)
    - .sessionpipe/files/<session id>/notes.pdf (application/pdf, 1.2 MB)
    

    (sizes in units of 1 024 bytes), framed like any other start.

If any file cannot be downloaded (a 404: gone or expired), is too long or too short, has another hash, or cannot be written, the command is acked failed with code file and a detail naming the file and the reason, nothing that was written for it is kept, and no session starts.

pool

Session sessionpipe:pool, the machine's account pool (§2 pool, PRIVACY.md §3): the only kind besides key.add that names no coding session. A receiver can only ask; the machine decides, acts and acks. The pool itself — which account serves a request — is the machine's policy and no command changes it: a request goes to the first account in the person's order that has room, and a session stays on its account while that account has room. The commands shape the order and the roster:

pool_action Takes Does
on — Turns the pool on: the machine points its harness at the daemon. Already on: delivered.
off — Turns it off and removes exactly what on wrote. The accounts stay.
enable · disable pool_account Puts an account in, or out of, rotation.
remove pool_account Removes the account and deletes its credential.
order pool_order The complete order: every account of the pool once, nothing else.
refresh pool_account? Reads the account's usage from the vendor now; without an account, every account's.
login.begin — Starts a sign-in for a new account. Acked delivered with detail = the pending login id, one space, the URL to open; the person opens it on any device, and the vendor's page hands them a code.
login.complete pool_account (the pending login id), pool_code Finishes it: the machine exchanges the code, learns the account's id and email, and adds it to the end of the order. A pending login the machine doesn't hold (expired after 10 minutes, or never begun) is refused pool_account.

Every check of §5 runs first. Then the machine answers:

  • delivered, with no mode and detail = its one-line summary of the pool as it now stands (on · 3 accounts · 2 with room · serving Fiction), or for login.begin the id and URL above. The daemon also sends a hello at once (§2), which is the state a receiver shows; detail is for the person who asked.
  • refused with pool_account for an enable, disable, remove, refresh or login.complete whose account (or pending login) the machine does not hold; pool_order for an order that does not name every account of the pool exactly once; pool_off for refresh, login.begin or login.complete while the pool is off (the machine calls the vendor only for a pool the person turned on, PRIVACY.md §3).
  • failed with code login when the vendor would not take the code, or pool when the vendor call a refresh or login.complete needs did not go through; detail says what, never a token.
  • unsupported when the daemon knows the kind but this machine cannot pool (no harness of the pool's kind installed, or a platform the listener does not run on). A daemon that predates the kind refuses it unknown_kind at the verifier, as it would any kind it doesn't know, so a receiver MUST NOT send pool to a machine whose last hello had no pool.

A pool command is a change to what every session on the machine runs through, so on, off, remove, order, disable and login.complete are verified by a fresh confirm (§3.2) or not at all: one that arrives on a day grant alone is refused confirm_required by the machine (vector 164 is the confirmed form; a stolen receiver session holds a day key, never the person's passkey or device key). enable, refresh and login.begin take a grant, as a prompt does. A receiver MUST ask for the confirm where the machine will want it, and MUST show which machine it is for. No command adds an account's credential from the receiver's side: login.complete carries a one-time code the vendor issued to the person, and the exchange happens on the machine.

Handoff (reply_to)

A prompt or a start MAY name reply_to (§3.1): a session on the same machine that gets the answer. "Ask Codex for a second opinion and bring it back here" is one command. The command runs exactly as it would without the field, and its acks are unchanged: exactly one final ack, with the reply where this section puts it. After that final delivered ack the daemon delivers the reply into reply_to as a message, through the modes above (the first that fits; reply_to's folder on the allowlist, §5 step 6; never a resume of a session a live process holds), framed:

Answer from <harness>:<short id> (sessionpipe handoff, asked via <receiver host>):

<reply>

<short id> is the answering session's short id (PROTOCOL.md §3) and <receiver host> the host of the receiver that carried the command. The machine places reply_to when the command arrives, as it places the session of any prompt (§5 step 6): a session it does not know, or one in a folder its allowlist excludes, makes the command no handoff at all — it runs and is acked as usual, nothing is relayed, and no event is written on that session, since a signer's say-so is no reason to write to a session the machine does not have or will not touch. The relay is not a command: it is not acked, it is never relayed further, and reply_to is read from the signed bytes and nowhere else. Its fate lives in the events (PROTOCOL.md §4): when the daemon takes the command it emits on reply_to a delegation.started with via: "handoff" and to the command's session, and when it is done a delegation.ended whose relayed says delivered, unsupported (no mode fits reply_to, or its folder is no longer allowed) or failed; delegation.started is emitted only for a reply_to the machine placed. A command whose final ack is not delivered relays nothing; its delegation.ended has ok: false, the ack's code or detail as error, and no relayed. The session that answers carries delegated_by: <reply_to> on its events (PROTOCOL.md §3), so a receiver can draw the lineage.

cancel and permission.answer never carry reply_to. A machine says it takes the field with handoff: true in its hello (§2); a receiver MUST NOT send reply_to to a machine that does not, and such a machine refuses the command bad_fields. The machine MUST deliver the relay at the mode its rules give reply_to and never wider: a handoff moves an answer, not a permission.

7. Attention lifecycle

attention.needed (kind permission) → optional verified permission.answer → attention.cleared with how: answered when a control answer or the terminal resolved it, timeout when the hook's window closed, cancelled when the turn ended without a decision, unknown when the sender cannot tell.

An attention is open from its attention.needed until its attention.cleared, or until the first event of its session, timed at or after it, that says its question is over:

  • a tool.ended of the thread that asked — the same agent_id, or neither event carrying one, which is the main thread — or one whose call_id is the attention's id. The tool ran, or the thread went on to another: either way it is no longer waiting. A tool.ended of another thread says nothing about it: a subagent's tools go on ending in the background while the main thread asks, and the main thread's while a subagent does;
  • where the main thread asked (no agent_id): a turn.ended or a turn.started. The turn it asked in is over;
  • where a subagent asked (agent_id): that subagent's subagent.ended, or a turn.ended whose background is 0, when nothing of the session's is left in flight to be asking. Any other turn.ended, and every turn.started, leaves it open: a main thread's turn ends half a second after a background subagent has asked, and another begins when some other task wakes it, with the terminal still asking for the subagent;
  • a session.ended.

An event timed before the attention.needed says nothing about it, however late it arrives: a sender records a hook when it gets to it, and the tool call before the question can be recorded after it. Nothing else closes one: not a tool.started, a subagent.started or another attention.needed.

Two limits of reading a question's end off later events, both known:

  • A thread that runs several tool calls side by side ends its own question at a sibling call's result, with the terminal still asking: a result does not say which question, if any, was about its call. An answer after that is refused no_attention, as any answer was up to 0.13.5 once a subagent's tool had ended.
  • A question can be over with no event that says so: the terminal refused it and only other threads report afterwards, or a subagent asked and the turn was interrupted. The machine goes on holding the hook until its window closes, and an answer in that time is handed to a hook the harness no longer waits on: acked delivered, and it changes nothing.

A receiver SHOULD show the Allow / Deny pair only while the attention is open, and a machine holds the prompt's hook for an answer for exactly as long (§6 permission.answer). Up to 0.13.5 the rule here was "the session's next hook event", and the reference daemon let go of every prompt at any tool result, Stop or new prompt of the session: with a subagent at work, the main thread's question was gone within a second or two and an answer to it was refused no_attention. A hook older than this rule tells the machine neither who asks nor when, and a machine SHOULD treat its prompts, and the events of a worker as old, as before. A receiver at tier 0 is sent no agent_id and no tool or subagent events (PROTOCOL.md §4): every question is the main thread's to it, and closes at the session's next turn event.

8. The waiter (sessionpipe wait, sessionpipe channel)

A session that should be reachable in place runs, as a background command, sessionpipe wait --session <harness>:<id>. It connects to the daemon's local socket (a Unix socket in the state directory, mode 0600; a named pipe on Windows) and blocks there — no network, no polling, no output. When a verified message for that session arrives, the daemon writes it to the waiter, the waiter prints the framed text and exits 0, and the harness wakes the session with the command's output. The session then starts a new waiter. A daemon restart does not end a waiter: it reconnects in silence. The local framing is the daemon's business and not part of this protocol.

A harness may stop a background command at a deadline of its own (Claude Code 2.1.286: 30 minutes by default, 2 hours at most unless BASH_MAX_TIMEOUT_MS is raised). Every such stop wakes the session once, so the waiter SHOULD be started with the longest timeout the harness allows.

A Claude Code session can instead be started with sessionpipe channel as a Claude Code channel (an MCP server the session's command line names after --channels or --dangerously-load-development-channels). It parks on the same socket as that session's waiter, says so (channel: true), and never exits: each message it is handed goes into the session as a channel notification, which the session takes as the person's own words, in its own thread and permission mode. Three rules make it safe:

  • Only an idle session. Claude Code folds a channel message that arrives during a turn into that turn, where the model need not answer it (on 2.1.314 it didn't), so a daemon MUST NOT hand a channel a message while the session is mid-turn: the turn's Stop takes it (turn).
  • Proven like an inbox write. Claude Code acknowledges nothing a channel sends, and drops a notification in silence where it didn't register the channel: a session started with --print (as every session the Claude app starts is) or through the Agent SDK, both of which ignore the flags, or an organization with channels turned off. A daemon MUST confirm within §6.3's window that the session began a turn for this message — the row that turn writes carries the message's id on its <channel> tag — and otherwise acks it failed with code held, tries no other mode, and hands that channel no further message: whatever it is handed next would be lost the same way.
  • Only where it can be one. sessionpipe channel MUST NOT park in a session that isn't interactive, whatever its command line names.

9. Hook output

The hook path writes nothing a harness acts on (PROTOCOL.md, SECURITY.md), with one exception: a control message the machine verified (§5). Then, and only then, a Stop hook may print a block with the framed message, and a PermissionRequest hook may wait on the daemon's socket and print the decision. Where control is not paired, neither hook waits or prints.

10. Acks

POST {control}/ack with control-ack.json: {"acks":[{"id","outcome","at","mode?","session?","copy_of?","code?","detail?","reply?","applied?"}]}. copy_of says the answer came from a copy of the session the command named, not from it (§6 prompt).

Outcome Meaning
progress Not final. The answer so far, whole, in reply, with seq rising per message; a receiver keeps the highest seq and MAY show it as it grows. Best effort: a daemon sends at most one every few hundred milliseconds and never retries one, since the next (or the final ack) supersedes it.
taken Not final. The machine verified the message and is delivering it; a final ack follows. Sent at once when delivery may take a while (a headless resume, a start).
delivered The harness took it; mode says how, session names a fork or a started session.
expired Past expires_at, or the hook's window closed, or the terminal answered first, or a cancel found the turn already over (§6 cancel).
unsupported No path for this kind on this harness, or for a cancel no path into the process that has the session (§6 cancel); detail says which.
failed A path exists and errored; detail says what. code cancelled: a start stopped by a cancel before its process began, so no session exists (§6 cancel). code file: a start's or a prompt's file could not be fetched, checked or written (§6). code model: the harness would not run the start's model; detail is its words. code login or pool: a pool command's vendor call (§6 pool). code held: a message was written into a live session's own inbox (§6.3), or handed to its channel (§8), and that session did not take it — no turn of its own began, or the turn that began was asked for by something else — so it may be holding it; nothing was duplicated, and nothing was tried on another mode. The machine does not try another.
refused Verification or the machine's policy said no; code says which (§5).

Every message is acked exactly once with a final outcome, optionally preceded by taken; a receiver MUST tolerate a duplicate ack for the same id. A receiver MUST NOT redeliver a taken message for 35 minutes (the longest headless turn plus slack); after that it MAY redeliver it, and a machine that already acked it finally repeats that ack, while one that lost it answers refused with replay. A receiver SHOULD log each ack's mode and its send → delivered time.

11. Fixtures

  • conformance/control-vectors/ — signed commands, valid and broken, with the verdict every verifier must reach (§5), and enrollment proofs (§2); 66–89 are a start's files (§3.1); 91–106 are a start's options and the closed field set, 107–108 a start in a folder under ~ (§3.1), 109–110 files on a prompt, 111–136 device keys (§2, §3.2): signatures for each purpose, purposes and rp ids swapped, WebAuthn fields where they don't belong, a device key a machine stored as a passkey, and key.add and enrollment of device keys, valid and broken; 137–164 the pool command (§6): every action valid, then a coding session named, no action, an unknown action, each action with another's field or without its own, an order too long, empty or repeating, a code with a line feed or too long, pool fields on a prompt, and an order swapped after signing; and 165–169 a handoff's reply_to (§3.1, §6): valid on a prompt and a start, on a cancel, malformed, and naming the command's own session. The reference verifier and every receiver that pre-checks MUST agree with all of them.
  • conformance/delivery/09–15 — receiver scenarios: a prompt polled and acked delivered; a message that expires unpolled; a double ack; a permission answer acked with its mode; a cancel acked unsupported; a message redelivered on every poll until acked; a start the machine refuses start_limit, final at once.
  • conformance/delivery/16–19 — receiver scenarios: a machine paired with no sink token, its status refused without the poll_key, pending with it, paired once a person approves, then 404 (§2; only for a receiver that declares control.open_pairing); a well-known that names control.session_tools (§6); a prompt's reply streamed as progress acks with seq rising, then delivered, and the poll empty after (§10); a second pairing start naming the machine the first one made, answered with that machine and a code, then paired once approved (§2, renewal).
  • conformance/delivery/20–22: a start refused permission, final at once; a delivered with applied; a hello with start.
  • conformance/delivery/23 — a receiver that sets control.inherit accepts a hello whose mode is inherit (§2).
  • conformance/delivery/24 — a hello with pool (§2): on, three accounts in order, one current, one limited with until, one disabled.
  • conformance/delivery/25–27 — a prompt delivered in place through the session's own inbox and acked delivered with mode: "inbox"; one written into that inbox that the session never took, acked failed with code held once and never retried (§6.3); and a copy's ack, naming the copy in session and the session the person addressed in copy_of, with why a copy was made and the mode the copy's own turn ran in (§6 prompt).
  • conformance/delivery/28 — a prompt handed to a session's channel that began no turn for it, acked failed with code held once and never redelivered (§8).
  • conformance/transcripts/claude-code/stop-block.jsonl — the rows Claude Code writes around a Stop hook's block that carries a message (§6 prompt): the turn's own last words, the block's row with the message's mark, the session's answer, and a turn a background task began after it. The reply a daemon reads for that message is the answer alone, wherever in those rows the hand-over fell.
  • conformance/control-held.json — whether a session is held, and by what (§6, What counts as held): registry rows of a window and of another process's run, a transcript written seconds ago with and without an end that accounts for it, and the edges of the two seconds and the ninety; a turn its own hooks report, for the ten minutes their word is good for; and a run asked to copy it that may have run in it instead.
  • conformance/control-session-ids.json — which session an id names (§6, A session is the one its harness filed under that id): found under exactly its name, not found in another spelling, and taken for a new session in any.
  • conformance/control-attention.json — when an open attention is over (§7): each kind of later event against a question the main thread asked and one a subagent asked, with the events of another thread, an event timed before the question, and an attention named by its tool call.
  • conformance/control-busy-window.json — when a message waits for a window's hooks before it is routed (§6.3, A busy window is asked twice): once, for a window that is busy by its registry row, whose hooks report to the machine, and that is the session's own.
  • conformance/control-cancel-waits.json — when a cancel that is through its checks waits before it acts (§6 cancel): for a start or a prompt for its session that was signed no later than it and is still in its own checks, and for nothing else.
  • conformance/transcripts/claude-code/stop-hook-ask.jsonl — an ask handed to a live session at its Stop (§13): the paragraph that names the message, by which the daemon finds the answer, and which a reader gives back in its plain form.
  • conformance/control-marks.json — what a message is named by when it is handed to a live session (§6 prompt): the mark a ULID makes, for a frame and for a channel's tag, and the ids that make none (short, lower case, with a prefix, a line break, words).
  • conformance/control-order.json — the order one session's messages are delivered in (§6 prompt): polls that list them in and out of signing order, a running turn, a message that arrives late with an earlier iat, ties, a refused one, two sessions.
  • conformance/delivery/30–33 — a cancel (§6): of a turn the machine is running as its own child process, acked taken and then delivered with the turn's mode; of a session open in its own window, acked unsupported with a detail to show; of a session nothing is running in, acked expired; and a start stopped before it began, acked failed with code cancelled.

12. Status

A sender MUST NOT act on control until it runs a daemon that verifies (§5). A receiver lists control in its capabilities only when it serves this whole document — pairing, the per-machine poll and acks — and declares control.signing. The draft this replaces (a per-session poll trusting the bearer token) was never implemented by any sender or receiver.

13. Asking another agent (sessionpipe ask)

A session MAY hand a task to another agent on the same machine and get the answer back: sessionpipe ask <harness|session> "<text>". Claude Code can ask Codex for a review, Codex can ask Gemini, any harness with a shell can ask any other. The daemon runs it, caps it, and the whole thing is visible as events.

Local only. The request reaches the daemon on its local socket (§8: a Unix socket in the state directory, mode 0600; a named pipe on Windows), never through a receiver. The trust boundary is the waiter's and the hooks': a process of the person's own user on this machine. No command carries an ask, and nothing a receiver sends can start one. A receiver learns only that asking is on (ask in the hello, §2). The wire between the command and the daemon, like the waiter's, is the daemon's business and not part of this protocol.

The request names the agent to ask — a harness this machine can run headlessly (§6 start), or an existing <harness>:<id> to continue — and carries the text (1 – 20 000 characters, not all whitespace, as §3.1), the folder it runs in (absolute; the caller's by default), the asking session as <harness>:<id> when the caller knows it (Claude Code and Codex put their session id in a session's environment), and its chain: every session that asked on the way here, outermost first, from the caller's own environment (below). It MAY name permission (read or safe) and model, as a start does.

The daemon checks, in order, and refuses with a code:

Code When
ask_off Asking is off on this machine. It is on by default; sessionpipe ask off turns it off and on back on.
harness The harness is not one this machine can run headlessly, or the session is not one it can deliver a prompt to.
folder The folder is not on the machine's allowlist (§5 step 6).
depth The chain already has two sessions: A may ask B, B may ask C, C MUST NOT ask. The daemon counts for itself as well, from the asks it has in hand (the one waiting on the asker, the one waiting on that one's asker), so a session that cleared its environment is no shallower.
loop The target session is the asker, one of the sessions in its chain, or a session that is itself waiting on an ask the daemon has in hand (its turn ends only when that answer is back). Counted under the names the daemon knows the sessions by: a Codex session asked under its start's name and asking under its own id is one session.
start_limit The machine's caps on starts (§6 start) are reached — they count an ask's start as a receiver's, and the headless slot is the same slot — or the asker, or the session asked, already has three asks in hand.
bad_fields Anything else out of shape.

The child runs at safe, or at read when the ask names it, and never wider than the machine's mode: an ask is a model's doing, not the person's, so a machine the person put in edits or auto for their receivers runs an asked agent at safe all the same, and an ask can no more turn approval off than a command can (§3.1). An existing session runs the ask under the same cap, narrower than its own level and never wider; an ACP agent, which takes no level (§6), runs as the machine's mode says. A new session is started as a start would be, an existing one gets the text as a prompt would (§6, the first mode that fits), with one more paragraph framing it, so the model knows where the task came from and that its answer goes back by itself:

<text>

(Asked by <harness>:<id> on this machine with sessionpipe ask. Answer here in full: your reply is returned to that session automatically. Don't run a command or call a tool just to send it.)

Handed to a live session in place (§6, waiter or turn), the paragraph names the message as well, … with sessionpipe ask, message <mark>. Answer here in full: …, as a receiver's message does in its header: the answer is found by it (§6 prompt).

The child's environment carries SESSIONPIPE_ASKED_BY=<harness>:<id> (the asker) and SESSIONPIPE_ASK_CHAIN=<chain…,asker> (comma-joined). Its hooks read the first and stamp delegated_by on every event of the child (PROTOCOL.md §3); a nested ask reads the second and carries the chain, which is how depth and loop are counted. Both are inherited like any environment, so a session that is not the child itself MUST reset them, as it resets SESSIONPIPE_LAUNCHED_BY.

The answer is the child's reply, as a delivered ack would carry it (§10), written to the caller when the turn ends; the daemon MAY hand over progress before that, as progress acks do. A refusal names its code; a failure says what went wrong. The ask is visible on the asking session, when it is known, as delegation.started with via: "ask" and delegation.ended (PROTOCOL.md §4), emitted by the daemon through the session's own outbox and sinks, with its own seq, so a receiver shows which session asked which, how long it took and (at tier 2) what came back.

At the terminal, sessionpipe ask keeps the two streams apart so a model can read the answer and nothing else: the child's id and any progress go to stderr, the answer alone to stdout, exit 0; a refusal or a failure goes to stderr with exit 1; a usage error exits 2, as sessionpipe wait does. --json prints {ask_id, session, reply, ms} instead. With no daemon answering on the socket it says what to run (sessionpipe ask on) and exits 1.

How a session learns the command. sessionpipe ask setup writes a short section into the harness's own instructions file (~/.claude/CLAUDE.md, ~/.codex/AGENTS.md) saying the command exists, which agents are installed, and that a session MUST NOT use it to send its own reply. That is the person's file and the person's choice; the hook path still never injects anything (§9, SECURITY.md).