# Control: the channel back

> This document is part of the sessionpipe protocol and is licensed under [CC BY 4.0](https://sessionpipe.org/legal/#spec-license). The words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are used as in RFC 2119. Status: **Draft** (see [VERSIONING.md](https://sessionpipe.org/protocol/versioning/)); 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](https://sessionpipe.org/protocol/http/#1-discovery-get-well-knownsessionpipe)).
A machine also trusts **device keys** (below): keys a client app holds in hardware
where it has no passkey to offer.

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

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

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

The pairing request and every hello MAY carry the machine's allowed `folders` and its
`mode` (`safe`: nothing that needs approval runs unattended; `auto`: the harness's auto
mode; `inherit`: no permission flag, so each harness starts in its own default on the
machine, as the person's own sessions there do — with one exception, §6 `prompt`, where
a relaunch may say again the narrowing a session itself chose), so a receiver can offer a folder
picker for a new session, and `files: true` when the machine takes attachments on a
`start` (§6), and `prompt_files: true` when it takes them on a `prompt` too. They are
informational: the machine checks its own list again (§5 step 6). The mode, like the
folders, is the person's choice at the machine's own terminal, and no command changes
it. In `inherit` a turn may run with permission checks off, if that is what the
harness's own settings say, so a machine MUST NOT run in `inherit` unless the person
chose it there.
A receiver that predates `inherit` reads it as an unknown mode and is likely to show
the machine as `safe`, so the person would approve, and later send to, a machine
described as safer than it runs. A receiver that shows `inherit` as what it is says
so with `control.inherit: true` in its well-known file ([HTTP.md](https://sessionpipe.org/protocol/http/) §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`](https://sessionpipe.org/schema/v1/control-hello.json)):

```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`](https://sessionpipe.org/schema/v1/control-hello.json)):

```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`](https://sessionpipe.org/schema/v1/control-key.json)):

```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](https://sessionpipe.org/protocol/http/#1-discovery-get-well-knownsessionpipe)) 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`](https://sessionpipe.org/schema/v1/control-command.json)):

```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](https://sessionpipe.org/protocol/adapters/)); 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](https://sessionpipe.org/protocol/)), 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](https://sessionpipe.org/protocol/adapters/)): 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`](https://sessionpipe.org/schema/v1/control-command.json)):

```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](https://sessionpipe.org/protocol/http/#3-control-control)),
and because the hash is signed on the device, a receiver cannot swap a file.

### 3.2 Signatures

Either path is enough:

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

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

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

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

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

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

### 3.3 The receiver's envelope

A receiver carries each command in [`control.json`](https://sessionpipe.org/schema/v1/control.json):

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

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

## 4. Where the limits come from

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

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

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

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

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

## 6. Delivery

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

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

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

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

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

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

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

Two rules hold across the in-place modes:

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

### 6.1 Homes

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

### 6.2 The session's mode

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

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

### 6.3 `inbox`

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

Before writing, a daemon MUST check all of:

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

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

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

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

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

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

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

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

### `prompt`

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

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

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

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

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

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

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

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

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

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

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

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

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

### `permission.answer`

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

### `cancel`

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

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

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

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

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

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

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

### `start`

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

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

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

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

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

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

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

1. Downloads each file (one after another is fine) with
   `GET {control}/files/{sha256}` and the machine's token. It MUST stop reading a
   response once it passes `size` bytes, and MUST NOT write a file whose length is not
   `size` or whose SHA-256 is not `sha256`.
2. Writes the files to `<cwd>/.sessionpipe/files/<session id>/<name>` (the id the
   command names, which is the session's name everywhere, above), creating
   `<cwd>/.sessionpipe/.gitignore` with the single line `*` when it is absent (the
   folder ignores itself, so a repository never picks it up). It never follows a
   symlink: `.sessionpipe`, `files` and the session's folder MUST be real directories
   inside `cwd` (a symlink there refuses the files), the session's folder MUST be new,
   and each file is created exclusively (never over an existing path) with mode
   `0600`.
3. Starts the session with the command's text followed by the list of files, paths
   relative to `cwd` (so the model reads them without leaving the project):

   ```
   <text>

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

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

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

### `pool`

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

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

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

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

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

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

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

<reply>
```

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

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

## 7. Attention lifecycle

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

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

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

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

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

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

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

## 8. The waiter (`sessionpipe wait`, `sessionpipe channel`)

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

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

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

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

## 9. Hook output

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

## 10. Acks

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

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

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

## 11. Fixtures

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

## 12. Status

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

## 13. Asking another agent (`sessionpipe ask`)

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

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

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

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

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

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

```
<text>

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

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

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

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

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

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