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:
- The person runs
sessionpipe control pair <receiver>. The daemon sendsPOST {control}/pair(control-pair.json) with the sink token the person configured, and gets a machine id, a pairingcode, aurland sixcheckdigits (control-pair-started.json). - 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). - The daemon polls
GET {control}/pair?code=…until it answerspairedwith the key and that assertion (control-paired.json). A code the receiver expires unapproved MAY be renewed from the same terminal: a newPOST {control}/pairnaming themachineit 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. - 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)wherestris (control-grant.json){"v":1,"t":"sessionpipe.grant","pub":"<day key SPKI, base64url>","iat":…,"exp":…,"rp":"<rp id>"}, withexp − iatat most 24 hours. Each command then carriescsig, the day key's ECDSA P-256 / SHA-256 signature over the exactcmdbytes (rawr‖s, 64 bytes, base64url), and the grant (strplus 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
- It parses the exact bytes it received and never re-serializes them.
machineis this machine.- 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
iatinside the grant's window (fromiat − 5 mintoexp), andcsigverifying against the grant's day key; or a confirm (either kind) that verifies oversha256(cmd). - 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_atbounds it too. - The
nonceis unseen for 25 hours. The machine records it before it runs the command, and a failure to record it refuses the command. - The session's folder, read from the session's own transcript and never from the
command, is on the machine's allowlist (for
start, thecwd; for a session the machine itself started where the harness's transcript doesn't say, the folder that start ran in). permission.answerwithallowneedsforto name an attention that is open right now on that session (§7).- For a
startwithpermission: the level is one the machine advertises for that harness, which is never above itsmode, or the command is refusedpermission. Withmodel: 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 refusedmodel. 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
psthat 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
turninstead); - 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
iatis at most 120 s from now, in either direction — judged by the signed time and never by the envelope'sat, 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
deliveredwith noreplywhen 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
sdkmode; ACPsession/cancelfor a session it holds inapimode (OpenCode/session/:id/abortand Codex app-server interrupt where those are wired). Ackeddeliveredwith that mode.A turn the daemon is running as its own child process (a
resume, afork, a headlessstart): 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 (apimode) and closes: one that has already exited is sent no signal. The cancel is ackedtaken, thendeliveredonce that process is gone, with the mode the turn was running in (resumeorfork). The turn's own message still gets its one final ack:delivered, itsreplywhat the turn had written, ending(The turn was cancelled.)as an ACP agent's cancelled turn does; aforkstopped after it named its copy still names it. A turn that ended by itself before the stop took effect leaves the cancelexpired. 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 (
modeonly where the turn had got far enough to have one); apromptwhose turn never started is ackeddeliveredwith(The turn was cancelled.)as its whole reply, and astartthat never began is ackedfailedwith codecancelled, 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 ackedfailedon top of it. For a session whosestartis 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
startor apromptfor 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 secondstartfor 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
forkis 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:
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 passessizebytes, and MUST NOT write a file whose length is notsizeor whose SHA-256 is notsha256.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/.gitignorewith the single line*when it is absent (the folder ignores itself, so a repository never picks it up). It never follows a symlink:.sessionpipe,filesand the session's folder MUST be real directories insidecwd(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 mode0600.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 nomodeanddetail= its one-line summary of the pool as it now stands (on · 3 accounts · 2 with room · serving Fiction), or forlogin.beginthe id and URL above. The daemon also sends a hello at once (§2), which is the state a receiver shows;detailis for the person who asked.refusedwithpool_accountfor anenable,disable,remove,refreshorlogin.completewhose account (or pending login) the machine does not hold;pool_orderfor an order that does not name every account of the pool exactly once;pool_offforrefresh,login.beginorlogin.completewhile the pool is off (the machine calls the vendor only for a pool the person turned on, PRIVACY.md §3).failedwithcodeloginwhen the vendor would not take the code, orpoolwhen the vendor call arefreshorlogin.completeneeds did not go through;detailsays what, never a token.unsupportedwhen 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 itunknown_kindat the verifier, as it would any kind it doesn't know, so a receiver MUST NOT sendpoolto a machine whose last hello had nopool.
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.endedof the thread that asked — the sameagent_id, or neither event carrying one, which is the main thread — or one whosecall_idis the attention's id. The tool ran, or the thread went on to another: either way it is no longer waiting. Atool.endedof 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): aturn.endedor aturn.started. The turn it asked in is over; - where a subagent asked (
agent_id): that subagent'ssubagent.ended, or aturn.endedwhosebackgroundis 0, when nothing of the session's is left in flight to be asking. Any otherturn.ended, and everyturn.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 itfailedwith codeheld, 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 channelMUST 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–89are astart'sfiles(§3.1);91–106are astart's options and the closed field set,107–108astartin a folder under~(§3.1),109–110fileson aprompt,111–136device 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, andkey.addand enrollment of device keys, valid and broken;137–164thepoolcommand (§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; and165–169a handoff'sreply_to(§3.1, §6): valid on apromptand astart, on acancel, 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 ackeddelivered; a message that expires unpolled; a double ack; a permission answer acked with its mode; a cancel ackedunsupported; a message redelivered on every poll until acked; a start the machine refusesstart_limit, final at once.conformance/delivery/16–19— receiver scenarios: a machine paired with no sink token, its status refused without thepoll_key,pendingwith it,pairedonce a person approves, then 404 (§2; only for a receiver that declarescontrol.open_pairing); a well-known that namescontrol.session_tools(§6); a prompt's reply streamed asprogressacks withseqrising, thendelivered, and the poll empty after (§10); a second pairing start naming the machine the first one made, answered with that machine and a code, thenpairedonce approved (§2, renewal).conformance/delivery/20–22: a start refusedpermission, final at once; adeliveredwithapplied; a hello withstart.conformance/delivery/23— a receiver that setscontrol.inheritaccepts a hello whosemodeisinherit(§2).conformance/delivery/24— a hello withpool(§2): on, three accounts in order, one current, one limited withuntil, one disabled.conformance/delivery/25–27— a prompt delivered in place through the session's own inbox and ackeddeliveredwithmode: "inbox"; one written into that inbox that the session never took, ackedfailedwith codeheldonce and never retried (§6.3); and a copy's ack, naming the copy insessionand the session the person addressed incopy_of, with why a copy was made and the mode the copy's own turn ran in (§6prompt).conformance/delivery/28— a prompt handed to a session's channel that began no turn for it, ackedfailedwith codeheldonce 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 (§6prompt): 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. Thereplya 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 acancelthat is through its checks waits before it acts (§6cancel): 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 (§6prompt): 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 (§6prompt): polls that list them in and out of signing order, a running turn, a message that arrives late with an earlieriat, ties, a refused one, two sessions.conformance/delivery/30–33— acancel(§6): of a turn the machine is running as its own child process, ackedtakenand thendeliveredwith the turn's mode; of a session open in its own window, ackedunsupportedwith adetailto show; of a session nothing is running in, ackedexpired; and astartstopped before it began, ackedfailedwith codecancelled.
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).