# sessionpipe: context for an agent You are helping a person use sessionpipe 0.11.0. Read this whole file before you run anything. Everything else on https://sessionpipe.org is Markdown too: the index is https://sessionpipe.org/llms.txt and the whole site is one file at https://sessionpipe.org/llms-full.txt. ## What it is An open protocol and a small CLI. Coding agents (Claude Code, Codex, Gemini CLI, Antigravity) fire hooks when a session starts, a tool runs, or the agent needs the person. sessionpipe installs those hooks, turns each one into a protocol event (`session.started`, `tool.ended`, `attention.needed`, 17 types in protocol 1), removes secrets on the machine, writes the events to a local outbox, and sends them to the destinations ("sinks") the person configured, filtered to a privacy tier per sink. A receiver is the server at a sink's URL. | Tier | What leaves the machine | |-----:|-------------------------| | 0 | session state and timing, machine, folder, repo, branch, model, title; the kind of attention needed | | 1 | + tool names, durations, ok/error, file paths, the attention message, subagents | | 2 | + the person's prompts and the assistant's replies, redacted | | 3 | + tool input and output, thinking, raw hook lines | ## Setting it up - To a receiver that offers pairing, one command per machine: `npx sessionpipe connect `. It prints six digits that the person approves with a passkey on a device where they are signed in to the receiver. You cannot approve it for them: tell them the digits and wait. - Hooks only, nothing sent anywhere: `npx sessionpipe install`, then `sessionpipe tail --tier 1` to watch events locally. - Add a destination later: `sessionpipe sink add --tier <0-3> --token `. - Check the result with `sessionpipe doctor`; read state with `sessionpipe status --json`. ## Rules to follow 1. Never choose or raise a tier on the person's behalf. Tier 2 sends what they type; tier 3 sends tool input and output. Ask, and say what the tier sends. 2. Before adding a sink, run `sessionpipe tail --tier ` and show the person what that tier would send. 3. Never edit the hook entries in an agent's settings by hand. `install` and `uninstall` own them; `uninstall` removes only what sessionpipe wrote. 4. Never paste a sink token or control key into a file, a commit or a chat. `sessionpipe secrets` says where each one is kept. 5. Messages to sessions ("control") are signed on the person's device and checked on the machine. Pairing (`sessionpipe control pair`) and choosing safe, auto or inherit mode are the person's to do at their own terminal. ## Commands Read from the CLI's own usage table when this file was built. `sessionpipe --help` prints the same line without running anything. ```sh sessionpipe connect [--machine NAME] [--rename-machine] [--allow-downgrade] [--tier 0-3] [--mode safe|auto|inherit] [--folder DIR]… [--status-line|--no-status-line] [--no-service] sessionpipe install [--] [--machine NAME] [--rename-machine] [--allow-downgrade] [--backfill DAYS] [--sink URL --tier N] [--status-line|--no-status-line] sessionpipe sink add [--tier 0-3] [--token T] [--secret S] [--pii] [--name N] sessionpipe sink list sessionpipe sink remove sessionpipe sink test sessionpipe secrets [move keychain|secret-service|file] sessionpipe secrets move keychain|secret-service|file sessionpipe status [--json] sessionpipe doctor [--json] sessionpipe tail [--session ID] [--tier 0-3] [--harness NAME] [--all] [--json] sessionpipe backfill [--days 30] [--] sessionpipe forget sessionpipe replay [--sink NAME] [--from-start] sessionpipe update [off|on] sessionpipe update off sessionpipe update on sessionpipe titles [on|off|refresh] [--days 30] sessionpipe titles on [--days 30] sessionpipe titles off sessionpipe titles refresh [--days 30] sessionpipe control pair [--folder DIR]… [--mode safe|auto|inherit] [--tier 0-3] [--token T] [--name N] [--no-service] sessionpipe control mode auto|safe|inherit sessionpipe control status sessionpipe control keys [remove ] sessionpipe control keys remove sessionpipe control off [] sessionpipe control run sessionpipe pool [--json] sessionpipe pool on sessionpipe pool off sessionpipe pool add --import | --login [--code -] sessionpipe pool remove sessionpipe pool enable sessionpipe pool disable sessionpipe pool order … sessionpipe pool refresh [] sessionpipe wait [--session :] sessionpipe version ``` ## Read next - Every command, in detail: https://sessionpipe.org/docs/index.md - Get started: https://sessionpipe.org/get-started/index.md - The protocol (for writing a receiver): https://sessionpipe.org/protocol/index.md - JSON Schemas for every message: https://sessionpipe.org/schema/v1/event.json and its siblings - What changed: https://sessionpipe.org/changelog/index.md - Source: https://github.com/spacesheep-dev/sessionpipe --- # Docs Everything the `sessionpipe` command does, and how the parts that run on their own behave. New here? [Get started](https://sessionpipe.org/get-started/) is the five-minute version; [the protocol](https://sessionpipe.org/protocol/) is the contract a receiver implements. The usage lines below are read from the CLI's own `--help` table when the site is built, so they are the ones your copy prints (`sessionpipe --help`). The longer sections are the package READMEs, included as they are in the repository. ## Commands ### Set up ```sh sessionpipe connect [--machine NAME] [--rename-machine] [--allow-downgrade] [--tier 0-3] [--mode safe|auto|inherit] [--folder DIR]… [--status-line|--no-status-line] [--no-service] ``` The whole setup against a receiver that offers pairing, in one command and one approval. It shows six digits; approve them on any device where you're signed in to the receiver. You get hooks for every agent and every Claude Code account here, a sink at tier 2, signed messages, keys in the keychain, a daemon that outlives logout, and the last 30 days backfilled. Run it again and it only fixes what's missing. ```sh sessionpipe install [--] [--machine NAME] [--rename-machine] [--allow-downgrade] [--backfill DAYS] [--sink URL --tier N] [--status-line|--no-status-line] ``` Finds the agents on this machine, writes their hooks and backfills 30 days into the local outbox. Nothing is sent until a sink exists. `--` hooks one even if it isn't detected; `--sink URL --tier N` adds a sink first. ```sh sessionpipe uninstall [--keep-state] ``` Removes the hook entries sessionpipe wrote and nothing else: every config file is otherwise byte-identical. The outbox stays under the state folder and is pruned after 30 days. ### Destinations ```sh sessionpipe sink add [--tier 0-3] [--token T] [--secret S] [--pii] [--name N] ``` Adds a destination: an HTTPS receiver, a file, or stdout. For a receiver it reads `/.well-known/sessionpipe`, caps the tier at the receiver's `max_tier`, stores the token and sends one heartbeat as a test. `--pii` turns on the PII pass for this sink. ```sh sessionpipe sink list ``` The sinks on this machine. ```sh sessionpipe sink remove ``` Removes one. ```sh sessionpipe sink test ``` Sends a heartbeat and then its `session.forgotten`, so the receiver keeps no phantom session from the test. ```sh sessionpipe secrets [move keychain|secret-service|file] ``` Where each key on this machine lives (the macOS Keychain, a desktop session's Secret Service keyring, or a `0600` file), and the best store this session can reach. ```sh sessionpipe secrets move keychain|secret-service|file ``` Moves sink and control keys to that store. A key it can't read here, or one the store refuses, stays where it is, and the command says which. ### Watch and check ```sh sessionpipe tail [--session ID] [--tier 0-3] [--harness NAME] [--all] [--json] ``` Follows the local outbox, one event per line, filtered to a tier (1 unless you say): exactly what a sink at that tier would receive. No server needed. `--all` starts from the beginning of each session instead of now; `--json` prints the filtered events whole. ```sh sessionpipe status [--json] ``` Sinks and what each has waiting, queued jobs, hook timing (p50 and p95 of the last 100) and the self-update state. `--json` is described [below](#status-json). ```sh sessionpipe doctor [--json] ``` Everything `status` checks, plus a live hook benchmark, each Claude Code account's sign-in, keychain access, and warnings for what looks wrong. ### History ```sh sessionpipe backfill [--days 30] [--] ``` Reads the last N days of sessions from the agents' own files into the outbox. ```sh sessionpipe replay [--sink NAME] [--from-start] ``` Sends what's waiting to a sink now, without waiting out a backoff. `--from-start` rewinds the sink's cursor first, so everything in the outbox goes again. ```sh sessionpipe forget ``` Asks every sink to delete one session (a `session.forgotten` event), then removes it from the local outbox. ### Session names ```sh sessionpipe titles [on|off|refresh] [--days 30] ``` Which one holds. A session the Claude app starts here is named by claude.ai a few seconds after its first message, and that name never reaches the machine; out of the box sessionpipe reads local files only, so such a session goes out under your first prompt. ```sh sessionpipe titles on [--days 30] ``` Claude Code sessions with a claude.ai link take claude.ai's name, read with the sign-in Claude Code keeps and sent to `api.anthropic.com` alone. It also asks about the last N days' sessions and renames the ones a receiver already has. A name you gave the session (`/rename`) still wins. ```sh sessionpipe titles refresh [--days 30] ``` Asks again about the last N days' sessions. ```sh sessionpipe titles off ``` Local files only again (the default). ### Accounts ```sh sessionpipe pool [--json] ``` The account pool: the accounts in order, the room each has left in its 5-hour and weekly windows, and when each resets. Nothing here runs until you ask for it; `install` and `connect` never touch the pool. ```sh sessionpipe pool add --import | --login [--code -] ``` `--import` copies every Claude Code login on this machine into the pool; `--login` signs in to another account (a URL, then the code to `--code -`). Credentials live in sessionpipe's own secrets store and never appear on an event or on the terminal. ```sh sessionpipe pool on ``` Points every Claude Code on this machine at the pool through the `env` block of its `settings.json`, and installs the daemon's service if it isn't there. New sessions use the pool from the moment it returns; running ones at their next start. ```sh sessionpipe pool off ``` Removes exactly what `pool on` wrote; the accounts stay. ```sh sessionpipe pool order … ``` The complete order: each request goes to the first account that has room. ```sh sessionpipe pool enable ``` ```sh sessionpipe pool disable ``` Takes an account out of the rotation, or puts it back, without forgetting it. ```sh sessionpipe pool remove ``` Forgets the account and its credentials. ```sh sessionpipe pool refresh [] ``` Reads usage from the vendor now, for one account or all of them; otherwise the daemon reads it every five minutes while the pool is on. ### Messages to your sessions ```sh sessionpipe control pair [--folder DIR]… [--mode safe|auto|inherit] [--tier 0-3] [--token T] [--name N] [--no-service] ``` Pairs this machine with a receiver for signed messages and installs the daemon. Asks once, at this terminal, whether sessions you message run in safe, auto or inherit mode. ```sh sessionpipe control mode auto|safe|inherit ``` Changes that answer; the running daemon picks it up within seconds. ```sh sessionpipe control status ``` Receivers, parked waiters, sessions mid-turn, open permission prompts. ```sh sessionpipe control keys [remove ] ``` The device keys enrolled here; `remove ` forgets one. ```sh sessionpipe control keys remove ``` ```sh sessionpipe control off [] ``` Tells the receiver, then forgets its token and keys. ```sh sessionpipe control run ``` The daemon itself, in the foreground: what launchd or systemd starts. One runs per machine; a second one finds the first, says so and exits 0. ```sh sessionpipe wait [--session :] ``` Parks in a session's background so a message reaches it in place, in about 60 ms with no network. Claude Code's session id comes from `CLAUDE_CODE_SESSION_ID`. ### Self-update ```sh sessionpipe update [off|on] ``` Installs the latest release now; a running daemon restarts onto it once nothing is in hand. ```sh sessionpipe update off ``` ```sh sessionpipe update on ``` The daemon's daily update check, off or back on. ```sh sessionpipe version ``` ## Control A receiver that lists `control` can carry a message from your phone to a session on this machine. It is signed on your device and **checked here**, against a key you enroll at this terminal; the receiver can queue a message but can never forge one ([spec](https://sessionpipe.org/protocol/control/)). ```sh sessionpipe control pair spacesheep.dev # one link: approve with your passkey; installs the daemon (this folder by default) sessionpipe control folders add ~/work/api # let messages reach sessions in another folder too (no new pairing) sessionpipe control folders [remove ] # the list, or stop allowing a folder sessionpipe control status # receivers, parked waiters, sessions mid-turn, open permission prompts sessionpipe control keys [remove ] sessionpipe control off # tell the receiver, forget its token and keys ``` Pairing through `npx` first installs that version globally and runs pairing from that persistent copy. On Homebrew, the daemon uses a matching stable Node link so a Node upgrade does not leave it pointing at a removed Cellar version. Services paired with older versions are **not** repaired by `install` or `update`. After upgrading, run `sessionpipe control pair ` again (without `--no-service`) to rewrite the service with persistent paths. On Linux, restart an already-running daemon afterward with `systemctl --user restart sessionpipe-control.service`; macOS pairing reloads the LaunchAgent. A new session started from the receiver may bring files (screenshots, PDFs: up to 10, 50 MiB together), whichever harness it is for. The daemon downloads each one, checks it against the hash you signed, saves it under `/.sessionpipe/files//` (a folder that git-ignores itself) and tells the session where they are. If any file is missing or doesn't match, the session doesn't start. One daemon per computer holds one long-poll per receiver and delivers each message the cheapest way the session allows: to `sessionpipe wait` parked in the session's background (in place, ~60 ms, no network), at the session's next Stop if it is mid-turn, through the Agent SDK for a session it started (if the SDK is installed), or as a headless `claude -p --resume` (a `--fork-session` copy when a live process holds it). A session stays reachable in place with zero idle turns by running, as a background command with the longest timeout it can (Claude Code: raise `BASH_MAX_TIMEOUT_MS`, else a background command is stopped after 2 hours at most): ```sh sessionpipe wait # Claude Code: the session id comes from CLAUDE_CODE_SESSION_ID ``` The waiter and next-Stop modes work in any Claude Code session that runs your hooks, the Claude desktop app's included, and need nothing else. The Agent SDK, resume and fork modes run the `claude` command, which has its own login (`claude auth login`); the desktop app doesn't share its login with that command. Permission prompts can be answered from the receiver too: while the terminal shows its prompt, the PermissionRequest hook waits for your signed answer, and whichever comes first wins. Nothing is ever auto-allowed. Safe mode (the default) runs headless turns with `--permission-mode dontAsk`, so nothing that needs approval runs while you're away. Auto mode runs them with `--permission-mode auto`; where Claude Code's settings turn auto mode off (`permissions.disableAutoMode`), Claude Code runs them in its default mode instead, which refuses whatever needs approval. Inherit mode (`sessionpipe control mode inherit`, chosen only at the machine) passes no permission flag, so each turn starts in the mode Claude Code picks by itself there, its settings' `permissions.defaultMode`: messaged sessions run the way your own do, `bypassPermissions` included if that is what you set. Codex and Antigravity sessions are reached headlessly too, when `codex` or `agy` is installed (the daemon looks on PATH, beside its own Node, in `~/.local/bin`, or where `SESSIONPIPE_CODEX` / `SESSIONPIPE_AGY` point). A new session is `codex exec --json -C ` or `agy -p … --output-format stream-json`; the next message is `codex exec resume ` or `agy --conversation `. Safe mode runs Codex in its `workspace-write` sandbox and agy with no permission flag (it denies, and the reply lists, what it wasn't allowed to do); auto mode passes `--approve-for-me` / `--mode accept-edits`; inherit mode passes no flag, so Codex follows the `approval_policy` and `sandbox_mode` in your `config.toml`. The bypass flags are never passed. Both CLIs pick a new session's id themselves, so the daemon keeps the one your start named: it records the pair in `~/.local/state/sessionpipe/control/aliases.json`, and your receiver keeps seeing one session under one id. A message to a Codex session someone has open (its rollout written in the last 90 seconds) is acked `unsupported` rather than resumed beside them. Before a Codex run the daemon checks `codex login status` (or `CODEX_API_KEY`) and says `codex login --device-auth` if it isn't signed in. Any other agent that speaks the [Agent Client Protocol](https://agentclientprotocol.com) is reached the same way: Gemini CLI, OpenCode, goose, Copilot CLI, Cursor, Droid, Devin, Kilo, Qwen Code, Cline and the rest of the ACP registry ([the list, with each one's launch line](../../spec/ADAPTERS.md#agents-driven-through-acp-control-only)). The daemon runs the agent's own CLI (`gemini --acp`, `opencode acp`, …) when it is installed, on PATH or where `SESSIONPIPE_ACP_` points (`SESSIONPIPE_ACP_GEMINI_CLI`), and never downloads one. It is the agent's client: a start opens a session in your folder, the reply streams back as the agent writes it, and the process stays up so the next message lands in it with no cold start (`api` mode); `cancel` works there too. An agent idle for ten minutes is closed, and a later message reopens the session where the agent supports `session/load`. Safe mode answers every permission the agent asks with a refusal, listed in the reply; auto mode allows. An agent that never asks (OpenCode, by its own settings) runs its tools as it would at your terminal. Signing in is yours to do at the terminal: an agent that wants it says so in the reply. ## Updates The control daemon keeps its own copy current. About a minute after it starts, and then once a day (the last check is kept in `~/.local/state/sessionpipe/control/update.json`, so a restart or a machine that sleeps doesn't ask more often), it reads `https://registry.npmjs.org/sessionpipe/latest`. When that is a newer release it waits until nothing is in hand — no message being delivered, no headless run or new session, no Agent SDK turn, no permission prompt waiting for your answer, no session of yours mid-turn — stops taking new messages (the receiver holds them meanwhile), runs `npm install --global --ignore-scripts sessionpipe@` into the prefix it was installed in, re-copies the hook launcher's files, and restarts onto the new code. A running turn is never interrupted. How it restarts depends on how it runs: - **launchd (macOS) or systemd (Linux)**, as `control pair` / `connect` set it up: the daemon exits with code 75 and the service starts it again. Every service file sessionpipe has written restarts on a non-zero exit, so older installs need nothing. - **Anything else** (a terminal, tmux, a container that starts `sessionpipe control run` itself): the old process stays as the new daemon's parent, with the same environment and output, and restarts it the same way after each later update. Whatever waits on the daemon (a shell, a container's init) keeps waiting. One daemon runs per machine: a second `sessionpipe control run` finds the first one answering on the local socket, says so, and exits 0. Only a global npm install that this user can write updates itself; a copy in the npx cache, a git checkout or a dev build is left alone. `sessionpipe status` and `sessionpipe doctor` say whether it's on, when it last checked and what it found; the daemon's log (`~/.local/state/sessionpipe/control/daemon.log` under launchd, `journalctl --user -u sessionpipe-control.service` under systemd) has one line per check and per update. ```sh sessionpipe update off # the daemon stops checking ("update_check": false in config.json) sessionpipe update on sessionpipe update # install the latest now; the daemon restarts onto it once idle ``` `SESSIONPIPE_NO_UPDATE_CHECK=1` turns it off too, but only where the daemon can see it: a launchd or systemd service doesn't inherit your shell's variables, while `sessionpipe update off` reaches it (it re-reads the config every ten minutes). ## Session names from claude.ai A session the Claude app starts on this machine gets its name from claude.ai, a few seconds after its first message, and claude.ai never writes that name to the machine. Out of the box sessionpipe reads only local files, so such a session goes out under your first prompt cut to 50 characters while the Claude app shows another name. ```sh sessionpipe titles on # Claude Code sessions with a claude.ai link take claude.ai's name sessionpipe titles refresh # ask again about the last 30 days' sessions (--days N) sessionpipe titles off # local files only again (the default) sessionpipe titles # which one holds ``` With it on, the worker asks claude.ai for the name of each Claude Code session that has a claude.ai link, a minute apart until claude.ai has named it, then every half hour. To ask, it reads the sign-in Claude Code keeps for that config dir (the macOS keychain item Claude Code writes, else `.credentials.json` in the config dir). It sends that token to `api.anthropic.com` alone and keeps it nowhere: not in a file, an event, a log or a sink. A token about to expire is skipped, never refreshed, so Claude Code's own login is never touched. A name you gave the session here (`/rename`) still wins, and so does a rename in the Claude app, which reaches the transcript without any of this. `titles on` (and `titles refresh`) also asks about every session of the last 30 days and sends each one a receiver already has under another name again, as `session.backfill` with claude.ai's name, so finished sessions are renamed too, not only the ones that run again. It prints how many it found named and how many it sent. ## The pool Claude Code can't change account mid-session, but its requests can. `sessionpipe pool` keeps several Claude accounts behind one loopback listener the control daemon runs on this machine; Claude Code is pointed at it through the `env` block of its `settings.json` (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, and the one flag that keeps its first-party features on). Each request goes to the first account in your order that has room, a session stays on its account while that account has room, and when the account's 5-hour or weekly window is used up the next request simply goes to the next account: the session notices nothing. The numbers each account reports arrive as `limits.observed` with `account_id`, so a receiver's usage page shows every account ([spec](../../spec/CONTROL.md#pool), [what it reads](../../spec/ADAPTERS.md), [privacy](../../spec/PRIVACY.md)). ```sh sessionpipe pool add --import # copy every Claude Code login on this machine into the pool sessionpipe pool add --login # sign in to another account: a URL, then paste the code to --code - sessionpipe pool on # write the env block; installs the daemon's service if it isn't there sessionpipe pool # the accounts in order, each with its room left and reset times sessionpipe pool order … # the complete order; enable | disable | remove | refresh too sessionpipe pool off # remove exactly what `on` wrote; the accounts stay ``` Nothing here happens until you run `pool add` or `pool on` yourself: `install` and `connect` never touch the pool. The credentials you add live in sessionpipe's own secrets store (the keychain where there is one), are sent only to the vendor's own endpoints for your own requests, and never appear on an event, in an ack or on the terminal. The vendor is called (requests forwarded, idle usage read every five minutes) only while the pool is on; `pool add --login` and `pool refresh` at the terminal are the exceptions, because you asked. Whether pooling is allowed by each account's plan is yours to check; sessionpipe doesn't change any limit. New Claude Code sessions use the pool from the moment `pool on` returns; sessions already running pick it up at their next start, since Claude Code reads `settings.json`'s `env` when it starts. While the pool is on, every Claude Code request on the machine depends on the daemon: if it isn't running, Claude Code gets "connection refused", and `sessionpipe pool` says so (`sessionpipe control run` in a terminal, or the service `pool on` installs). The daemon runs for the pool alone, with no receiver paired; `control off` keeps the service while the pool is on. While the daemon runs, every `sessionpipe pool` command goes to it over the local socket, since it holds the listener, the session pins and the credentials; with no daemon the command runs here and the daemon reads the result when it starts. From a receiver, the same actions arrive as a signed `pool` command; turning it on or off, removing or reordering accounts, and finishing a sign-in need a fresh confirm with your passkey or device key, never a day grant alone. `sessionpipe uninstall` runs `pool off` first. ## Status JSON `sessionpipe --help` (or `-h`) prints usage and exits successfully before running the command. This also works after operands and for nested commands, such as `sessionpipe sink remove example --help` and `sessionpipe control pair example --help`. `sessionpipe status --json` prints one JSON object. Its shared fields use the same names and meanings as `doctor --json`: | Field | Shape / meaning | |---|---| | `version`, `machine`, `state`, `config` | Strings; state and config are absolute paths. | | `harnesses` | Object keyed by harness name; each value is an array of `{file, state, note?}` hook checks. `file` uses `~` for home, as doctor does. Includes detected **or configured** harnesses. | | `sinks` | Array of `{name, url, tier, max_tier, pii, control, paused, pending_sessions}`. `tier` is the configured tier; the effective tier is `min(tier, max_tier ?? 3)`. `max_tier` and `paused` are `null` when unset. No token or secret is included. | | `timing` | `{n, p50, p95}` from the last 100 local hook timings, in milliseconds; `null` when unavailable. | | `update` | Doctor's update summary: `{auto, off, last_check, latest, last_error, pending, updated, failed}`. `auto` is boolean; absent details are `null`. | | `jobs` | Number of entries waiting in the local jobs directory; zero when absent. | `pending_sessions` counts outbox session files with bytes beyond that sink's cursor, including paused sinks; it counts files, not individual events. Empty collections are `{}` / `[]`. Status omits doctor's active hook benchmark, login/keychain checks, and warnings. Both commands still check hook installation, which can run `gemini --version` (up to four seconds) when `tools.enableHooks` is unset. JSON is an output format, not a promise of constant-time diagnostics. Plain `sessionpipe status` keeps its existing human output. ## Conformance - `harness//*.json` — `{ input: { argv, stdin, env, files }, expect: [events] }`, recorded from **real** hook payloads (secrets scrubbed; `npm run fixture-scan` gates it). `"*"` in an expected event is a wildcard for `id` and `time`. An empty `expect` is a payload the adapter reports nothing for (Codex's ephemeral threads). - `redaction/secrets.json`, `redaction/pii.json` — `[{ in, out }]` vectors for the rulesets. - `short-id.json` — `{ harness, id, short_id }` vectors for a session's short id (spec/PROTOCOL.md §3): what a receiver shows and searches by must reach these. - `delivery/*.json` — receiver scenarios: dedup, 413 split, 403 tier, retry, forget, control, pairing renewal. A control scenario's `enqueue` step is the receiver's own test door: the runner signs the message with a test key it enrolled there, and `{run}` / `{run12}` are the run id. A `hello` step posts its `body` as the machine's hello (`POST {control}/hello`, the machine's token), so a scenario can say what the machine advertised before a start. - `remote//*.json` — `{ request, response: { status, body }, expect }`, answers **recorded** from a harness's own service (scrubbed), for the facts an adapter may ask it for once the person turned `remote_titles` on: claude.ai's name for a Claude Code session (spec/ADAPTERS.md, the Facts row). `expect.title` is the name a reader must take, or `null` for none. Read by `packages/core/test/claude-code.test.ts`. - `transcripts//*.jsonl` — transcript files as the harness writes them, scrubbed, for what a reader must keep and skip (spec/ADAPTERS.md, the Transcript row). Read by `packages/core/test/transcripts.test.ts`. - `control-vectors/*.json` — signed control commands, valid and broken, and enrollment proofs, each with the verdict every verifier must reach (spec/CONTROL.md §5). Run by `packages/core/test/control-vectors.test.ts`; `npm run vectors` writes the ones not yet there and keeps the rest (`SESSIONPIPE_WRITE_VECTORS=all` rewrites every one). Filled in M1. Run with `npm run conformance`, or against a live receiver with `npx @sessionpipe/conformance https://host`. ## For agents Every page on this site is also plain Markdown, so an agent can read it without a browser or a scraper. Hand it the first file below and it can find the rest. | File | What it is | |------|------------| | [`/agent.md`](https://sessionpipe.org/agent.md) | Context for an agent helping a person with sessionpipe: what it is, every command, and the rules to follow (tiers, approvals, keys). Paste it into Claude Code, Codex or any agent. | | [`/llms.txt`](https://sessionpipe.org/llms.txt) | The index, in the [llms.txt](https://llmstxt.org) format, linking each page's Markdown. | | [`/llms-full.txt`](https://sessionpipe.org/llms-full.txt) | The whole site in one file: this page, the protocol, the changelog. | | `/index.md` | Any page's Markdown, such as [`/docs/index.md`](https://sessionpipe.org/docs/index.md). Each page links it in its `` as `rel="alternate" type="text/markdown"`, and has a button that copies it. | | [`/schema/v1/`](https://sessionpipe.org/schema/) | JSON Schemas for every message, at the URLs their `$id`s name. | | [`/changelog/feed.xml`](https://sessionpipe.org/changelog/feed.xml) | Releases, as an Atom feed. | All of it is rebuilt from the repository on every deploy, the same way the pages are, and the Markdown carries absolute links so a copied page still works on its own. --- # Get started ## To a receiver, in one command ```sh npx sessionpipe connect your-receiver.example ``` Run it on each machine your agents run on: a laptop, a lab workstation over ssh, a cloud box. It prints a link and six digits. Open the link on any device where you're signed in to the receiver, check that its page shows the same digits, and approve with that device's passkey. The link is what pairs the machine, so open it yourself and don't paste it anywhere; the digits are only a check that the page belongs to this terminal. That approval hands the machine both its control token and its sessions key, so there is nothing to copy. Then, without asking: - **Hooks** for every harness here, and every Claude Code account (`~/.claude`, `~/.claude-*`). Hooks run `~/.local/share/sessionpipe/sessionpipe-hook`, which outlives a Node upgrade or `nvm uninstall`. - **A sink at tier 2** (prompts and answers, secrets removed; `--tier` to change it), capped at the receiver's `max_tier`. - **Signed messages**: the control daemon, in safe mode unless `--mode auto`, for the folder you ran it in, or the folders your recent sessions ran in when you ran it from your home folder (never the home folder itself). - **Keys** in the macOS Keychain, or the Secret Service keyring in a Linux desktop session; otherwise a `0600` file, which is what a headless server has. `sessionpipe secrets` shows where each one is. - **The daemon keeps running after you log out** (systemd linger on Linux; a background launchd agent on a Mac nobody is logged in to). - Each Claude Code account it finds, and whether it's **signed in** as this machine sees it: a message you send starts a headless run with that login. Run it again any time; it only fixes what's missing. `sessionpipe doctor` checks all of the above. ## In the Claude desktop app The desktop app runs the same hooks, so its sessions report like any other. To message one from another device, say this in that session: ```text Run `sessionpipe wait` as a background command, with the longest timeout you can. ``` Your messages then arrive in that session, in the app, and its answers go back to you by themselves. A message sent while the session is mid-turn lands when the turn ends. This needs no `claude` command and no separate sign-in. The `claude` command only matters when the daemon has to start Claude Code outside the app: a new session you start from the receiver, or a message to an app session that is neither running `sessionpipe wait` nor mid-turn (it runs headless, outside the app). That command has its own login, which the app doesn't share: `claude auth login` once if you want those too. ## 1. Install the hooks ```sh npx sessionpipe install ``` Detects the harnesses on your machine (Claude Code, Codex, Gemini CLI, Antigravity, and more as adapters land), writes their hooks, installs itself globally if run through npx (a hook cannot start from an npx cache), and backfills the last 30 days of sessions into a local outbox. Nothing leaves the machine yet. ## 2. Watch, with no server ```sh sessionpipe tail --tier 1 ``` One event per line from the local outbox, filtered to a tier. This is exactly what a tier-1 sink would receive. ## 3. Add a sink ```sh sessionpipe sink add https://your-receiver.example --tier 1 --token sessionpipe status ``` `sink add` reads the receiver's `/.well-known/sessionpipe`, caps the tier at its `max_tier`, stores the token with mode 0600 and sends one heartbeat as a test. `status` shows the last 2xx per sink, queue depth and hook timing. ## The reference receiver ```sh npx sessionpipe-receiver --port 7357 --token --max-tier 3 ``` or in Docker: `docker run -p 7357:7357 -v sessionpipe:/data ghcr.io/spacesheep-dev/sessionpipe-receiver`. Open `http://localhost:7357/` for the page: sessions grouped needs-you / working / recent, live.

sessionpipe-gui

A separate open-source project ([repository](https://github.com/spacesheep-dev/sessionpipe-gui)): every session on your machines, live, on one board, with search across your agents' history. The desktop apps for macOS and Linux are on the [home page](https://sessionpipe.org/#gui) and in [every release](https://github.com/spacesheep-dev/sessionpipe-gui/releases); they update themselves. On any system with Node 20 or later, Windows included: ```sh npm i -g https://github.com/spacesheep-dev/sessionpipe-gui/releases/download/v0.6.2/sessionpipe-gui-0.6.2.tgz sessionpipe-gui ``` It serves the board on `localhost:7373`, with a QR code for your phone. The desktop app needs Node 20 or later too; on first run it starts the GUI's receiver on this machine. The app shows everything; sending from it comes later. For now you answer a prompt or send the next message from a browser, a phone's included, signed with your passkey. ## Send to spacesheep On Pro or Team, one command and one approval at spacesheep.dev/pair: ```sh npx -y sessionpipe@latest connect spacesheep.dev --mode auto ``` To report sessions only (any plan), make a **sessions-only** key at [spacesheep.dev/settings/api-keys](https://spacesheep.dev/settings/api-keys) (pick *Sessions only* beside the name), then: ```sh npx -y sessionpipe install --sink https://spacesheep.dev --tier 2 --token ``` A sessions-only key can report sessions and turns and nothing else: no MCP, no deploys, no reads. It is the safe kind to leave in a sink config on every machine. A full `ss_` key still works here, but if it leaks from that file it opens the whole account. ## Send to a Slack webhook `examples/slack-webhook/` in the repository is a Cloudflare Worker that receives tier-0 events and posts `attention.needed` to a channel: "needs you: Bash on misha-air". ## Uninstall ```sh sessionpipe uninstall ``` Removes only the entries it wrote; every config file is byte-identical otherwise. --- # sessionpipe-gui The control room for the agents you already run. Every coding-agent session on your machines on one live board, and every one you ever ran a keystroke away. Answer, steer and start them from your laptop or your phone. No account, no cloud, no telemetry. A separate open-source project ([repository](https://github.com/spacesheep-dev/sessionpipe-gui)) that speaks the sessionpipe protocol: plain files on your disk, no database, no account, no service of ours in between. Release 0.6.2. ## What it does - **One live board.** Every session, grouped in the order you would look: needs you, working, stale, idle, done. - **Answer in place.** A session reads as a chat: allow or deny the open prompt, send the next message, attach images, stop a turn. - **One inbox.** Every open permission, question and error from every machine, newest first. - **Search all of it.** ⌘K across every session and this machine's own agent history, sessions from before sessionpipe included, with the network down. - **Start a session anywhere.** Pick the machine, the agent and the folder; that machine's own sessionpipe starts it. The GUI runs nothing. - **Signed by you.** Every command is signed by your passkey and checked on the machine that runs the agent. ## Desktop apps The same UI in a Tauri 2 app on the system's own web view. It connects to the receiver on this machine, and on first run it can start one for you as a service (that needs Node 20 or newer). A menu bar item and a Dock badge count what needs you; on a Mac it also sends, signed with a key in the Secure Enclave and Touch ID. It updates itself. | Platform | Download | |---|---| | macOS 13 or newer, Apple silicon and Intel | [.dmg](https://github.com/spacesheep-dev/sessionpipe-gui/releases/download/v0.6.2/sessionpipe_0.6.2_universal.dmg), signed and notarized | | Linux, x86-64 | [.deb](https://github.com/spacesheep-dev/sessionpipe-gui/releases/download/v0.6.2/sessionpipe_0.6.2_amd64.deb) · [AppImage](https://github.com/spacesheep-dev/sessionpipe-gui/releases/download/v0.6.2/sessionpipe_0.6.2_amd64.AppImage) | | Anything with Node 20, Windows included | npm, below | ## On your phone The same app at phone size, served by your computer. Scan the QR code sessionpipe-gui prints and the board is there. Sending needs https and a passkey: the same Wi-Fi with the GUI's own certificate (`--tls`), or Tailscale from anywhere. Add it to your home screen and it opens like an app. A native iPhone build runs in the simulator; it isn't released yet. ## Install with npm ```sh npx github:spacesheep-dev/sessionpipe-gui # try it straight from GitHub npm i -g https://github.com/spacesheep-dev/sessionpipe-gui/releases/download/v0.6.2/sessionpipe-gui-0.6.2.tgz # keep it on your PATH ``` Then `npx sessionpipe install` on each machine whose sessions you want on the board. It runs on port 7373. `sessionpipe-gui --demo` fills the board with a synthetic team. --- # Receivers Who speaks the protocol. A listing carries a conformance badge that CI re-checks by running `sessionpipe-conformance` against the origin at tier 0. | Receiver | max_tier | Capabilities | Conformance | |----------|:--------:|--------------|-------------| | [sessionpipe-receiver](https://github.com/spacesheep-dev/sessionpipe/tree/main/packages/receiver) (reference) | 3 | events, backfill, forget, control, native | pending (M3) | | [sessionpipe-gui](https://github.com/spacesheep-dev/sessionpipe-gui) ([desktop app](https://sessionpipe.org/#gui)) | 3 | events, backfill, forget, control | pending | | [spacesheep.dev](https://spacesheep.dev) | 2 | events, backfill, forget; sessions-only tokens | pending (M5) | ## List yours Open a [receiver listing](https://github.com/spacesheep-dev/sessionpipe/issues/new?template=receiver.yml) issue, or a PR that adds one JSON entry under `website/src/receivers/`. CI runs the conformance suite against the URL; the badge follows the result. ## Build one Serve `GET /.well-known/sessionpipe` and `POST` the events endpoint it names; deduplicate on `(harness.name, session.id, session.seq)`; answer 403 above your `max_tier`; issue sink tokens that can post events and nothing else ([why](https://github.com/spacesheep-dev/sessionpipe/blob/main/SECURITY.md#faq)). The [HTTP binding](https://sessionpipe.org/protocol/http/) is the whole contract, and `examples/minimal-receiver/` is 40 lines. --- # sessionpipe protocol v1 > 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/)). ## 1. What this is A coding-agent harness (Claude Code, Codex, Gemini CLI, Antigravity, Devin, Cursor, …) fires hooks as a session runs. sessionpipe turns those hooks into a small, uniform stream of **events** about the session — started, working on a tool, needs you, ended — filtered to a **privacy tier** the person chose per destination, and delivers them to any **receiver** over HTTP (or to a file, or to stdout). A receiver may send **control messages** back (answer a permission prompt, queue a message for the next turn). The protocol carries **facts and timestamps**. It never carries a verdict: what "stale" means, when a session that never said goodbye is "done", and what a session is about are a receiver's readings, made from the facts. This document defines the envelope, the session block, the event vocabulary and the tiers. [HTTP.md](https://sessionpipe.org/protocol/http/) is the wire binding, [PRIVACY.md](https://sessionpipe.org/protocol/privacy/) the tiers and rulesets in detail, [CONTROL.md](https://sessionpipe.org/protocol/control/) the channel back, [ADAPTERS.md](https://sessionpipe.org/protocol/adapters/) the per-harness mapping, [VERSIONING.md](https://sessionpipe.org/protocol/versioning/) how this changes. ## 2. Envelope Every event is one JSON object with exactly these top-level members. The normative shape is [`schema/v1/event.json`](https://sessionpipe.org/schema/v1/event.json), generated from the reference implementation's types. ```json { "protocol": 1, "id": "01K6ABCXYZ0123456789ABCDEF", "type": "tool.ended", "time": "2026-09-28T01:03:11.204Z", "tier": 1, "harness": { "name": "claude-code", "version": "2.1.290", "event": "PostToolUse" }, "session": { "id": "8c132906-8c3f-4814-870a-afc3d05e1d2e", "seq": 412, "machine": "misha-air", "cwd": "~/spacesheep", "repo": "github.com/micmmakarov/spacesheep", "branch": "main", "model": "claude-fable-5-1", "title": "Open-source session hooks", "title_source": "harness", "url": "https://claude.ai/code/session_01…", "account_id": "a1b2…", "attended": true, "parent_id": null }, "privacy": { "rulesets": ["secrets@1"], "pii": false }, "data": { "tool": "Bash", "call_id": "toolu_01…", "ms": 1830, "ok": true } } ``` | Field | Type | Req. | Rule | |-------|------|:----:|------| | `protocol` | integer | yes | `1`. Bumped only on a breaking change ([VERSIONING.md](https://sessionpipe.org/protocol/versioning/)). | | `id` | ULID | yes | Unique per event. The idempotency key for control acks. | | `type` | string | yes | Dotted, lowercase (`^[a-z][a-z0-9]*(\.[a-z][a-z0-9_]*)+$`), or the literal `native`. From §4. A receiver MUST accept and store an unknown type; it MUST NOT reject the batch for it. | | `time` | RFC 3339 | yes | The sender's clock, UTC, millisecond precision (`…Z`). Receivers MUST also stamp their own arrival time. | | `tier` | 0–3 | yes | The tier the event was filtered to. A receiver MUST answer 403 to an event above its `max_tier` and MUST NOT silently keep it. | | `harness` | object | yes | `name` from the registry in ADAPTERS.md (`claude-code`, `codex`, `gemini-cli`, `antigravity`, `devin`, `cursor`, `copilot-cli`, `droid`, `kiro`, `opencode`; other `[a-z0-9-]` names allowed); `version` and `event` (the harness's own event name) optional. | | `session` | object | yes | §3. `id` and `seq` required, the rest optional. | | `privacy` | object | yes | `rulesets`: the redaction rulesets that ran (`secrets@1` MUST be present above tier 0); `pii`: whether `pii@1` ran. | | `data` | object | yes | Per type, §4. Extra keys are allowed; a receiver MUST keep them. | ## 3. The session block Every event carries the session as the sender knows it **at that moment**. A hook fired a second after start may know only the id and the folder; the worker learns the title, the model and the link later from the harness's own files. A receiver MUST merge with COALESCE semantics: a later event whose field is absent or empty never blanks an earlier full value. | Field | Rule | |-------|------| | `id` | The harness's own session id; ≤ 128 chars of `[A-Za-z0-9._:-]`. | | `seq` | Integer ≥ 0, monotonic per (harness, session) per sender. With `harness.name` and `session.id` it is the deduplication key. Order of arrival is not guaranteed; `seq` is the order. | | `machine` | A name for the sending machine: the hostname unless the person configured one. A sender MUST NOT guess it; an empty field beats a wrong one. | | `cwd` | The folder the session started in. The home directory is written `~`. | | `repo` | The `origin` remote as a URL with any credentials stripped. | | `branch` | The checked-out branch; absent on a detached HEAD. | | `model` | The model id the harness reports. | | `title`, `title_source` | The session's name and where it came from: `custom` (the person named it), `harness` (the tool's own titler), `first-ask` (the first prompt, cut). A receiver SHOULD never replace a better source with a worse one. | | `url` | A link that opens the session (a Remote Control link, a web remote). | | `account_id` | An opaque id for the harness account, never an email. A receiver the person paired with for control learns the email that goes with it from the pairing request and the daemon's hellos (`accounts`, [CONTROL.md §2](https://sessionpipe.org/protocol/control/#2-keys-and-enrollment)). A Claude Code home that runs on another API has no harness login; its account is the endpoint, `api-` and 16 hex characters of the SHA-256 of the endpoint's origin, the same on every machine that uses it, and the hellos name it by the endpoint's host. | | `attended` | Whether a person attends the session, as the harness says: `true` when one is at it or drives it from one of its own clients (a terminal, a remote-control link, a desktop app), `false` for a run nobody watches, such as a headless run a script started. Absent when the harness does not say, and a receiver MUST read absent as unknown, never as `false`. A later event may change it (a headless session resumed in a terminal). | | `parent_id` | For a subagent's own events: the parent session's id. `null` or absent otherwise. | | `launched_by` | The session that started this one, as `:` (the form [CONTROL.md](https://sessionpipe.org/protocol/control/) uses for a command's session): a script that runs `claude -p` for a session sets `SESSIONPIPE_LAUNCHED_BY` in the child's environment, and the hook carries it. It names at most the nearest launcher: environments are inherited, so anything that starts a further session MUST unset or reset it, and a value that names the session itself is ignored. Absent for a subagent, whose launcher is its `parent_id`, and when nothing says. A receiver MAY group on it and MUST NOT read it as a subagent. | **Short id.** Where a session is named to a person (a log line, a notification, a search box), senders and receivers use its short id: the first 8 hex digits, lowercase, of `sha256("sessionpipe.session:" + harness.name + ":" + session.id)`. It is derived from fields every event carries and is never sent. The id's own prefix is not a short form: Codex ids are time-ordered (UUIDv7), so two sessions started in the same minute share their first eight characters. A receiver SHOULD accept a typed prefix of a short id and resolve it against its sessions newest first, and MUST list the candidates rather than pick one when more than one matches. Vectors: `claude-code` with `8c132906-8c3f-4814-870a-afc3d05e1d2e` → `58ab6602`; `codex` with `01a10860-a43f-7ce2-b12f-a7911ae8a880` → `ae08c6ab`. ## 4. Event vocabulary *Tier* is the lowest tier that carries the type; a sink below it never sees it. Per-type `data` shapes are normative in [`schema/v1/event-data.json`](https://sessionpipe.org/schema/v1/event-data.json). | type | tier | data | Fires when | |------|:----:|------|------------| | `session.started` | 0 | `source`: `startup` · `resume` · `clear` · `compact` · `fork` · `unknown` | The harness's session-start hook; for Antigravity, the first `PreInvocation` of a conversation. | | `session.heartbeat` | 0 | — | At most once per 60 s while a turn is running, for sinks **below tier 1** (which get no tool events). Lets a tier-0 receiver show "working". | | `session.ended` | 0 | `reason`: `clear` · `logout` · `exit` · `other` | The harness's session-end hook. Four harnesses never fire one (ADAPTERS.md); a receiver infers "done" from silence and MUST present that as its own reading. | | `session.backfill` | 0 | `started_at`, `last_at`, `turns?` | One per historical session found in local files at install. `seq` is 0 and the session block carries what the files say. Sent again, with the session's next `seq`, when the machine learns a better name for a session it already sent (`sessionpipe titles on`: the name a harness's own service gave it); a receiver applies its session block as it does any event's. | | `session.forgotten` | 0 | — | The person ran `sessionpipe forget`. A receiver MUST delete everything it holds for the session and answer as for any accepted event. | | `turn.started` | 0 | `turn_id?`, `submitted_by?` (the harness's own word for who submitted the prompt), `injected?` (`true`: the prompt text is a block the harness wrote); tier 1 adds `prompt_chars` | A prompt was submitted. | | `turn.ended` | 0 | `turn_id?`, `reason`: `stop` · `interrupt` · `error`, `ms?`, `background?` (integer); tier 1 adds `error` (the harness's error class when the reason is `error`) | The Stop / AfterAgent / agentStop hook; Claude Code's `StopFailure` (a turn that ended on an API error) with `reason: error`. `background` is how much background work the harness reports still in flight as the turn ends, each piece of which wakes the session when it finishes (a background shell command, a monitor, a subagent, a workflow); it is a count, never what that work runs. A session whose last turn ended with `background` > 0 is paused waiting on its own work, not done: a receiver MAY show it as working until its next event. Absent when the harness doesn't say. | | `turn.transcript` | 2 | `turn` (integer), `user`, `assistant` (each ≤ 20 000 chars, redacted), `at` | Read from the harness's transcript file by the worker after a turn ends; one event per completed exchange, in order. | | `turn.progress` | 2 | `turn` (integer), `user`, `assistant` (each ≤ 20 000 chars, redacted; over the cap the `assistant` side keeps its **end**), `at` | The turn still running: its prompt as the turn starts, then what the assistant has said so far, at most every 20 s on a tool call and only when it changed. `turn` is the number its `turn.transcript` will carry. A receiver keeps the latest per session and drops it once that `turn.transcript` (or the turn's end) arrives; it is a reading, never history. OPTIONAL for senders. | | `tool.started` | 1 | `tool`, `call_id?`, `turn_id?`, `agent_id?` (set when a subagent made the call); tier 3 adds `input` | Pre-tool hook. OPTIONAL for senders whose harness has none. | | `tool.ended` | 1 | `tool`, `call_id?`, `turn_id?`, `agent_id?`, `ms?`, `ok`, `error?`; tier 3 adds `input`, `output` (each ≤ 64 KB) | Post-tool hook. A tool name is `[A-Za-z0-9_.:-]{1,120}`; MCP tools keep the harness's own spelling. | | `files.changed` | 1 | `paths[]` (repo-relative or `~`-relative) | File-edit hooks (Cursor, Kiro, Goose), or paths lifted from a write tool's input. | | `attention.needed` | 0 | `attention_id`, `kind`: `permission` · `question` · `elicitation` · `idle` · `error`; tier 1 adds `tool`, `message` (redacted, ≤ 200) | PermissionRequest, Notification, permission.asked. Tier-0 receivers learn "needs you" without the words. | | `attention.cleared` | 0 | `attention_id`, `how`: `answered` · `cancelled` · `timeout` · `unknown` | The next event that proves the prompt was resolved (a tool ran, a turn ended), or a control answer. | | `subagent.started` / `subagent.ended` | 1 | `agent_id`, `agent_type?` | Subagent hooks; the subagent's own events carry `session.parent_id`. | | `context.compacted` | 1 | `trigger`: `manual` · `auto` | Compaction hooks. | | `limits.observed` | 1 | `windows[]` (1–8): `id` (the harness's own name for the window, passed through as the harness spells it: `five_hour`, `seven_day`, `primary` …), `used_percent` (0–100; above 100 once a spend limit is passed), `resets_at?`, `window_minutes?` (only where the harness or its documentation says the length); `plan?`; `account_id?` (the account the numbers belong to, spelled as `session.account_id` is) | The account's usage limits, when the harness reports new numbers: Codex's rollout after a turn, Claude Code's status line where the person made it ours, or the machine's account pool reading the vendor's rate-limit headers for a request it served (ADAPTERS.md). The numbers belong to the account, not the session: a receiver SHOULD keep the newest per (harness, `data.account_id`, else `session.account_id`), or per machine when there is neither. A sender MUST set `account_id` when the numbers belong to another account than the session's own (a pooled session served by a second account, CONTROL.md §6 `pool`) and MAY set it otherwise; absent, the numbers are the session's account's. Each event is the whole set: a window it leaves out is not reported (Claude Code drops one once it resets). A window whose `resets_at` has passed has reset; a receiver SHOULD NOT show its last `used_percent` as current. OPTIONAL for senders. | | `native` | 3 | the raw hook JSON | Only on the native lane (HTTP.md §6); never emitted by the client. | Rules for all types: - A sender MUST emit at least `session.started` (or `session.backfill`) before any other event of a session, and SHOULD emit `turn.started` / `turn.ended` for every prompt its harness reports. - A sender MUST NOT emit a type at a tier below the type's tier (a `tool.ended` in a tier-0 event is a protocol error; a receiver MAY reject it with reason `tier_type`). - `attention_id`, `turn_id`, `call_id`, `agent_id` are opaque strings the sender chooses; when the harness provides one (`tool_use_id`, `prompt_id`) it SHOULD be used as is. - **Who started a turn.** `turn.started.submitted_by` is the harness's **own** word for who submitted the prompt (Claude Code: `user` · `sdk` · `system` · `loop_wakeup` · `schedule_wakeup` · `poll_event`). It is a different vocabulary from `session.started.source`, which is this spec's own enum; a receiver MUST NOT merge the two. `turn.started.injected` is `true` when the prompt **text** is a block the harness wrote around something rather than words a person typed. Both fields are OPTIONAL for senders. - A sender whose harness supplies the word MUST forward it verbatim and MUST NOT map it onto a vocabulary of this spec, because a harness may extend its own list at any time. The value is a bare word, `[A-Za-z0-9][A-Za-z0-9_.-]{0,39}`, because tier 0 is not redacted ([PRIVACY.md](https://sessionpipe.org/protocol/privacy/) §2); a sender whose harness supplies something else, or supplies nothing, MUST omit the field rather than guess. - `injected: true` says only that the prompt is **not the person's words**. It MUST NOT be read as "no person caused this turn": a harness may hand a person's message to a session inside an injected block, as Claude Code does with a ``. A sender MAY decide it from the prompt's shape, which is a reading and can be wrong in either direction; each sender's gaps belong in [ADAPTERS.md](https://sessionpipe.org/protocol/adapters/). - A receiver MUST read an absent field as **unknown** — never as "a person submitted this" — and MUST keep a value it does not recognise. A receiver that acts on either field MUST NOT read silence as the opposite. - A receiver that separates the person's own activity from the harness's (a recency board) MAY act on `injected: true` **together with** whatever else it knows about who caused the turn, or on a `submitted_by` that the sender's harness documents as non-human. A harness MAY fire a further `turn.started` for a `turn_id` already seen, when it absorbs a message into a running turn; a receiver MUST NOT let a later `injected: true` reclassify a turn it has already accepted without it. ## 5. Tiers What each tier carries. The full rules, the rulesets and guidance are in [PRIVACY.md](https://sessionpipe.org/protocol/privacy/). | What | 0 | 1 | 2 | 3 | |------|:-:|:-:|:-:|:-:| | Session id, state events, timing | ✓ | ✓ | ✓ | ✓ | | Machine, cwd, repo, branch, model, title, url, account_id, attended | ✓ | ✓ | ✓ | ✓ | | Attention kind | ✓ | ✓ | ✓ | ✓ | | Tool names, durations, ok/error | — | ✓ | ✓ | ✓ | | File paths touched | — | ✓ | ✓ | ✓ | | Attention message, prompt length, subagents, compactions | — | ✓ | ✓ | ✓ | | The account's usage limits (`limits.observed`) | — | ✓ | ✓ | ✓ | | Prompts and assistant text (`turn.transcript`, `turn.progress`) | — | — | ✓ | ✓ | | Tool input and output, thinking, raw lines | — | — | — | ✓ | | Secrets ruleset | n/a | always | always | always | | PII ruleset | opt-in | opt-in | opt-in | opt-in | **Filtering is a pure function.** `filter(event, tier, pii)` returns the event with fields removed and strings redacted. It removes by **allowlist per type**, never by denylist, so a field the spec does not name at a tier is not sent at that tier. The same function runs in the conformance runner, so a receiver can prove what a tier-N event may contain. **Tier per sink, capped by the receiver.** A sink is configured at a tier; the receiver's well-known file declares `max_tier`; the sender uses the lower. ## 6. Invariants a sender keeps 1. **Never lose, never duplicate.** The outbox is append-only; a sink's cursor advances only on a 2xx; every event carries `seq`; a receiver deduplicates on (harness.name, session.id, session.seq). An outage costs delay, never an event. 2. **Secrets never leave.** `secrets@1` runs on every string above tier 0 before an event reaches the outbox, and cannot be turned off per sink. 3. **The hook path is invisible.** The process a harness spawns reads stdin, writes one job file, spawns a detached worker and exits 0; it writes to stdout only what the harness requires. Nothing in this protocol may block or alter the harness. 4. **No telemetry.** A sender reports to the sinks the person configured and to nobody else. ## 7. Conformance A sender or receiver is conformant when it passes the fixtures under `conformance/`: adapter fixtures (real hook payloads → expected events), redaction vectors, and delivery scenarios (HTTP.md §7). `sessionpipe-conformance` runs them. --- # HTTP binding > 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/)). The protocol's events ([PROTOCOL.md](https://sessionpipe.org/protocol/)) travel to a receiver over HTTPS. This document is the binding: discovery, the batch request, responses and errors, retry, idempotency, the control long-poll and the native lane. The header names and signature scheme are those of [Standard Webhooks](https://www.standardwebhooks.com/), verbatim. ## 1. Discovery: `GET /.well-known/sessionpipe` At the receiver's origin root. A sender reads it when a sink is added and re-reads it at most daily (24 h cache); a receiver MAY set `Cache-Control`. ```json { "protocol": [1], "max_tier": 2, "capabilities": ["events", "backfill", "forget", "control", "native"], "auth": ["bearer"], "endpoints": { "events": "/api/sessionpipe/v1/events", "control": "/api/sessionpipe/v1/control", "native": "/api/sessionpipe/v1/native" }, "batch": { "max_events": 50, "max_bytes": 262144 }, "control": { "wait_max_s": 25, "signing": { "rp_id": "receiver.example", "algs": [-7, -257], "key_types": ["passkey", "device"] }, "files": true } } ``` | Field | Rule | |-------|------| | `protocol` | The protocol integers the receiver accepts. | | `max_tier` | The highest tier it will store. A sender MUST cap its sink's tier at this value. | | `capabilities` | `events` is required. `backfill`: accepts `session.backfill`. `forget`: honours `session.forgotten`. `control`: serves the control endpoint. `native`: serves the native lane. Additions to the protocol are announced here ([VERSIONING.md](https://sessionpipe.org/protocol/versioning/)). | | `auth` | `bearer` only in v1. | | `endpoints` | Paths relative to the origin, or absolute URLs. | | `batch` | The largest request the receiver takes. A sender MUST NOT exceed either bound. Defaults when absent: 50 / 262 144. | | `control` | Required when `capabilities` lists `control`. `wait_max_s`: the longest long-poll it will hold. `signing.rp_id`: the WebAuthn rp id control keys are enrolled for; `signing.algs`: the COSE algorithms it accepts (`-7`, `-257`). `signing.key_types`: OPTIONAL, the kinds of key the receiver stores and carries whole (`passkey`, `device`; CONTROL.md §2); absent means `["passkey"]`. `files`: OPTIONAL, informational; `true` when it accepts `files` on a `start` and serves `{control}/files/{sha256}` (§3). `inherit`: OPTIONAL; `true` when it shows a machine whose `mode` is `inherit` as that, on its pairing page and wherever it shows the mode, and never as `safe` or `auto`; a daemon pairs in `inherit` mode only with a receiver that says so. ([CONTROL.md §2](https://sessionpipe.org/protocol/control/#2-keys-and-enrollment)) | Schema: [`well-known.json`](https://sessionpipe.org/schema/v1/well-known.json). ## 2. Events: `POST {events}` Body `{"events":[…]}` — 1 to `batch.max_events` events, at most `batch.max_bytes` bytes ([`batch.json`](https://sessionpipe.org/schema/v1/batch.json)). Request headers: | Header | Rule | |--------|------| | `content-type` | `application/json` | | `authorization` | `Bearer ` — the token the person configured for the sink. | | `sessionpipe-protocol` | `1` — the batch's protocol integer, so a receiver can answer 400 without parsing. | | `webhook-id` | OPTIONAL. A unique id for this delivery attempt's message (retries reuse it). | | `webhook-timestamp` | OPTIONAL. Unix seconds. | | `webhook-signature` | OPTIONAL. `v1,` — HMAC-SHA256 over `..` with the secret behind the `whsec_` prefix (base64-decoded). Several space-separated signatures MAY be present; one match is enough. A receiver that verifies MUST reject a timestamp more than 5 minutes off its clock. | Response `202` with [`batch-response.json`](https://sessionpipe.org/schema/v1/batch-response.json): ```json { "accepted": 47, "duplicates": 3, "rejected": [{ "id": "01K6…", "reason": "tier_type" }], "max_tier": 2 } ``` `accepted + duplicates + rejected.length` MUST equal the number of events sent. A rejected event is one the receiver will never store (its reason says why); the batch as a whole still succeeded and the sender's cursor moves past it. `max_tier` repeats the well-known value so a sender learns a lowered cap without a discovery round trip. ### Errors | Status | Meaning | Sender behaviour | |-------:|---------|------------------| | 400 | Malformed body, or `{"reason":"unsupported_protocol","supported":[1]}` | Drop the whole batch; log it. | | 401 | Bad or missing token | Pause the sink; `doctor` says so. Never retry in a loop. | | 403 | `{"reason":"tier_above_max","max_tier":N}`: an event's `tier` exceeds the receiver's maximum | Lower the sink's tier to `max_tier`, re-filter, retry. | | 413 | Batch too large | Halve the batch and retry; a single event over the limit is dropped and logged. | | 429 | Too many requests, with `retry-after` | Wait as told, then retry. | | 5xx, network error, timeout | Receiver unavailable | Retry with backoff 2 s · 10 s · 60 s · 5 min · 30 min, then every 30 min, forever. | Bodies of 4xx/5xx responses SHOULD be [`error.json`](https://sessionpipe.org/schema/v1/error.json). **The sender's cursor moves only on 202.** ### Idempotency A receiver MUST deduplicate on `(harness.name, session.id, session.seq)` and count a repeat in `duplicates`. Order of arrival is not guaranteed; `seq` is the order. A receiver SHOULD keep at least the highest `seq` per session plus a window of recent values, so a replay after an outage costs nothing. ### Time A receiver MUST record its own arrival time beside `time`. The difference is the delivery latency the project measures (target: p50 under 2 s on a laptop). ## 3. Control: `{control}` Only when the well-known file lists `control`. The messages, keys and checks are [CONTROL.md](https://sessionpipe.org/protocol/control/); this section is the wire. One machine daemon holds **one** long-poll per receiver, whatever the number of sessions. | Request | Auth | Body → answer | |---------|------|---------------| | `POST {control}/pair` | the person's sink token, or none (with a `poll_key`) where `control.open_pairing` | [`control-pair.json`](https://sessionpipe.org/schema/v1/control-pair.json) → `201` [`control-pair-started.json`](https://sessionpipe.org/schema/v1/control-pair-started.json) | | `GET {control}/pair?code=` | the person's sink token, or the `poll_key` for an open pairing | → `200` [`control-paired.json`](https://sessionpipe.org/schema/v1/control-paired.json) (`pending`, `paired` with key, proof and, on a machine's first pairing, its token; `expired`) | | `POST {control}/hello` | the machine's token | [`control-hello.json`](https://sessionpipe.org/schema/v1/control-hello.json) → `204` | | `GET {control}?machine=&wait=` | the machine's token | → `200` [`control-poll.json`](https://sessionpipe.org/schema/v1/control-poll.json) when a message is waiting, `204` when none arrived within `wait` (≤ `wait_max_s`) | | `POST {control}/ack` | the machine's token | [`control-ack.json`](https://sessionpipe.org/schema/v1/control-ack.json) → `202` | | `POST {control}/off` | the machine's token | → `204`; the machine, its token and its queue are gone | | `GET {control}/files/{sha256}` | the machine's token | → `200` with the file's bytes and its `content-type`; `404` when there is no such file for this machine's account (never uploaded, or expired) | - The machine's token opens these machine calls (hello, poll, ack, off, files) for that one machine and nothing else of the account. A receiver MUST refuse a sink token on them, and the machine's token everywhere else. - A receiver MUST keep re-delivering a message on every poll until it is acked or its `expires_at` passes, then drop it. A `taken` ack pauses redelivery for 35 minutes (CONTROL.md §10); a final ack ends it. A machine MAY receive the same message twice; the signed nonce makes the second one a `replay`. - A poll is also the machine's heartbeat: a receiver MAY show a machine that polled within `2 × wait_max_s` as online, and SHOULD hand a session the waiter (CONTROL.md §8) only while its machine is online. - **Files.** A receiver that accepts `files` on a `start` or a `prompt` (CONTROL.md §3.1) holds each file's bytes, named by the base64url SHA-256 the command carries, and serves them at `GET {control}/files/{sha256}` to the machines of the account that uploaded them and to no one else. It MUST keep them at least until the command's `expires_at`. How the bytes reach the receiver is the receiver's business. The machine checks length and hash itself (CONTROL.md §6) and stops reading past the signed `size`; a receiver SHOULD send `content-length`. A receiver SHOULD NOT queue a `start` with `files` for a machine whose hello did not say `files: true`, nor a `prompt` with `files` for one whose hello did not say `prompt_files: true`. - `401` on a machine call means the token is gone (the machine was removed): the daemon stops polling that receiver and says so. `404` on `/pair?code=` means no such pairing. ## 4. Native lane: `POST {native}/` Raw hook JSON exactly as a harness's own HTTP hook handler sends it (Claude Code `type: "http"`, Copilot CLI). The receiver normalises it with core's adapter, stores it at the tier it declared for the lane (≤ 1), and records `privacy.rulesets: []` — **no redaction ran at source**. This lane exists for a receiver you own on a machine you own; a public receiver SHOULD NOT advertise `native`. ## 5. Forget A `session.forgotten` event (any tier) asks the receiver to delete everything it holds for that session: events, transcripts, derived data. A receiver that lists `forget` MUST do so before answering 202 (or within a stated retention window, documented on its `/.well-known` origin), and MUST answer later reads of the session as though it never existed. ## 6. Transport requirements - HTTPS on any host that is not `localhost` / `127.0.0.1` / `[::1]`. - A sender's request timeout is 15 s; a worker that cannot reach a sink leaves the backlog for the next worker. - A sender MUST send `session.*` and `turn.*` events of one session in `seq` order within a batch; across batches order is best effort. ## 7. Delivery scenarios (conformance) The runner sends and expects, in this order; each is a fixture under `conformance/delivery/`: | Scenario | Send | Expect | |----------|------|--------| | accept | a valid tier-0 batch | 202, `accepted` = n | | dedup | the same batch again | 202, `duplicates` = n | | oversize | a batch over `max_events` or `max_bytes` | 413 | | tier | an event with `tier` > `max_tier` | 403 with `max_tier` | | protocol | `sessionpipe-protocol: 99` | 400 `unsupported_protocol` | | auth | a wrong bearer (`auth: "wrong"` in the fixture) | 401 | | unknown-type | `type: "x.y"` | 202, stored, retrievable | | forget | `session.forgotten`, then a read | 202, then nothing | | control | a queued prompt, a poll, an ack | 200 with the message; 204 after the ack | | control-expired | a message that expires unpolled | 204 | | control-double-ack | the same ack twice | 202, 202 | | control-permission | a permission answer, a poll, an ack with its mode | 200; 202 | | control-unsupported | a cancel, acked `unsupported` | 202; 204 after | | control-redelivery | two polls with no ack | the same message both times | --- # Privacy: tiers and rulesets > 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/)). Sessions are personal. A prompt names colleagues, a path names a client, a pasted config carries a key. The protocol's answer is **data minimisation by construction**: a sink gets a tier, the tier is an allowlist, and two rulesets scrub what remains. ## 1. Tiers | Tier | Name | Carries | |-----:|------|---------| | 0 | presence | Session id, state events (`session.*`, `turn.started`, `turn.ended`, `attention.*` with only `attention_id` and `kind`), timing, machine, cwd, repo, branch, model, title, url, account_id, attended, launched_by. Enough for "working / needs you / done" and a link to open the session. `turn.started` carries `submitted_by` and `injected`: who started the turn, and whether its prompt was the harness's own block — never any of the prompt's words, but `injected` is one bit read off the prompt's first characters, where the prompt's bare length (`prompt_chars`) is tier 1. `submitted_by` is a bare word (PROTOCOL.md §4) because tier 0 is not scrubbed by `secrets@1`. | | 1 | actions | + tool names, `call_id`, durations, ok/error text, `files.changed` paths, attention `tool` and `message` (redacted, ≤ 200 chars), `prompt_chars`, subagents, compactions, the account's usage limits (`limits.observed`: percentages, reset times, plan). Enough for an activity feed. | | 2 | conversation | + `turn.transcript`: the person's prompts and the assistant's text, redacted, ≤ 20 000 chars a side, and `turn.progress`, the same for a turn still running. Enough for memory, search, recaps. | | 3 | full | + tool `input` and `output` (≤ 64 KB each), thinking where the harness exposes it, the native lane's raw lines. Full replay; meant for a receiver you own. | Rules: - **Allowlist per type.** `filter(event, tier, pii)` keeps, for each type, only the fields the table in [PROTOCOL.md §4](https://sessionpipe.org/protocol/#4-event-vocabulary) names at that tier or below, and drops the whole event when its type's tier is higher. It never works by removing named fields from an unknown shape. A field this spec does not mention at a tier is not sent at that tier. - **Per sink.** A person may send tier 0 to a team board and tier 2 to their own memory. The tier is a property of the sink, not of the machine. - **Capped by the receiver.** `max_tier` in the well-known file wins when lower. A receiver MUST 403 an event above its cap; it MUST NOT quietly keep it. - **Default is 0.** A sink added by hand without `--tier` is tier 0. The installer asks once per sink, shows what each tier sends, and remembers the answer. - **Every event says what ran.** `privacy.rulesets` and `privacy.pii` are on every event, so a receiver can tell an unredacted native-lane row from a client row. ## 2. Rulesets ### `secrets@1` (always, above tier 0) Runs on every string an event carries above tier 0, before the event reaches the outbox. It cannot be disabled per sink. It removes the **value** and keeps the sentence: `password: [redacted]`. What it matches: | Class | Examples | |-------|----------| | Vendor token shapes | `ghp_…`, `github_pat_…`, `glpat-…`, `xox[abposre]-…`, Slack and Discord webhook URLs, `sk-…` (OpenAI, Anthropic, DeepSeek, OpenRouter), `sk_live_` / `rk_test_` (Stripe), `whsec_…`, `AIza…`, `ya29.…`, `GOCSPX-…`, AWS access key ids, `xai-`, `gsk_`, `pplx-`, `hf_`, `pcsk_`, `npm_`, `pypi-`, SendGrid, Shopify, DigitalOcean, Linear, Notion, Supabase, Figma, Doppler, Granola, spacesheep `ss_`, Telegram bot tokens, JWTs, PEM private-key blocks (including one cut off mid-paste). | | URL credentials | `scheme://user:password@host` → the password goes, user and host stay. | | Authorization headers | `Bearer`, `Basic`, `Token` followed by a credential. | | Assignments | `NAME=value`, `NAME: value`, `NAME => value` where NAME names a secret (`DB_PASSWORD`, `apiKey`, `client-secret`, `*_KEY`, `*token*` …) — but not `password_hash`, `tokenizer`, `max_tokens: 4096`, and not a bare `pass:` (a count after "Verification pass:" is prose, not a credential). Only a secret name may consume a value, so `https:` never swallows a `?token=` behind it. | | Prose | "password is Hunter2!", "the token was …", "set the password to …" — only when the value looks generated (letters and digits, or three character classes), so "the password is wrong" stays. | Placeholders are left alone: ``, `${VAR}`, `process.env.X`, `string`, `[redacted]`, a member path, a call. The rules are **linear** (bounded repetition, no nested quantifiers); the adversarial vectors pin that a 50 000-space run or `password_` × 5000 returns in milliseconds. The ruleset is idempotent. Vectors: `conformance/redaction/secrets.json`. The reference implementation is `packages/core/src/privacy/secrets.ts`; every other copy (a server-side re-check, a CLI) MUST import it or run its vectors. ### `pii@1` (opt-in, any tier) A second pass, on by `--pii` per sink. It reduces identifiers, it does not promise anonymity: | Rule | Effect | |------|--------| | Email addresses | → `[email]` | | Phone numbers (E.164 and common national forms) | → `[phone]` | | Home directory paths | → `~` (already the rule for `session.cwd`; here applied inside every string) | | `session.machine` | → the first 8 hex characters of its SHA-256 | | `session.account_id`, and `limits.observed`'s `account_id` | dropped | Vectors: `conformance/redaction/pii.json`. Tiers and rulesets govern events. The control lane is not a sink: it talks only to a receiver the person paired with by approving it with their passkey, and its pairing request and hellos may name the machine's harness accounts with their emails, or an API endpoint's host for a home with no login (`accounts`, [CONTROL.md §2](https://sessionpipe.org/protocol/control/#2-keys-and-enrollment)), so that receiver's board can label `session.account_id`. No event carries an email or a host for that. ## 3. What a sender never reads An adapter uses a harness's documented hooks API and reads files the person's own harness wrote on the person's own machine (its transcript, its session registry, its config's account id, or the API endpoint its settings name — that one value, never the token beside it). It never reads a credential file, never calls a vendor API, and never sends a token of the harness's anywhere. [ADAPTERS.md](https://sessionpipe.org/protocol/adapters/) states this per harness with the documentation URL. One exception is the person's own to make, and off until they do: with `remote_titles` on (`sessionpipe titles on`), the Claude Code adapter reads Claude Code's sign-in on this machine to ask claude.ai for a session's name, the one fact claude.ai never writes to the machine. The token goes to `api.anthropic.com` alone, for that read; it is never stored, logged, put in an event, or sent to a sink. Only the name comes back, as `session.title`. **The other exception is the account pool, and only after the person turns it on** ([CONTROL.md §6 `pool`](https://sessionpipe.org/protocol/control/#pool), [ADAPTERS.md](https://sessionpipe.org/protocol/adapters/) *Pool*). A person with several accounts for one harness may run `sessionpipe pool on` and `pool add` at the machine's own terminal. From then on, and only then, the machine's daemon holds the credentials of **the accounts the person added** (copied from the harness's own login with `pool add --import`, or signed in with `pool add --login`), in sessionpipe's own secrets store, and sends each one only to **that vendor's own endpoints**, only for **the person's own requests** (the harness's requests, which it routes, and the vendor's usage and token-refresh calls that keep those accounts usable). Nothing is read, called or sent before `pool on` or `pool add`; `pool off` removes what `pool on` wrote; `pool remove` deletes the credential. No credential, no token and no email reaches an event, an ack, a log or a receiver: a receiver learns the accounts' ids, labels and emails only in the hello, as it does the harness's own accounts above, and their usage only as `limits.observed`. Nothing here extends to any other harness, to any other vendor, to any other endpoint, or to anything `install` or `connect` does. ## 4. For receivers *This section is guidance, not legal advice.* Session events can be **personal data**: names in prompts, an account id, paths that name a client or a colleague. If you operate a receiver for anyone but yourself, you are the controller of what it stores. - **The tiers are your data-minimisation tool.** Declare the lowest `max_tier` your purpose needs. A receiver serving more than its operator SHOULD declare `max_tier` ≤ 1. - **`session.forgotten` is the deletion path.** Honour it fully (events, derived data, backups on their schedule) and say in your documentation how long that takes. - **Document retention.** Say how long events are kept and delete on schedule. - **Access requests** can be answered from the per-session file: the protocol's storage unit is one session's events, in order. - **Secrets can still arrive.** `secrets@1` is good, not perfect; treat every stored string as though it might contain one. Never log event bodies. - **The native lane carries unredacted data.** Do not enable it on a receiver other people send to. - **`launched_by` can name a session you never saw.** The launcher and the session it started may report to different sinks. The id is opaque: it says the two are related and nothing else. ## 5. What sessionpipe itself never does - No telemetry: the client reports to the sinks the person configured and to no one else. The website has no analytics. - No hosted receiver: the project operates no server that receives anyone's data. - The optional daily version check is a plain read of the npm registry, off with `SESSIONPIPE_NO_UPDATE_CHECK=1`. --- # 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 `. 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), 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. 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. 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 `); `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` | `:`. 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. | | `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` 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":"","iat":…,"exp":…,"rp":""}`, 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": "", "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⏎⏎⏎ ``` where `⏎` is one line feed (U+000A) and there is none at the end. `` is `grant`, `confirm` or `pair`. `` is the receiver's rp id exactly as the machine recorded it at pairing. `` 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`, `start_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 (§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 | | `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 waiter | 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 a fork, and later messages to it go to the same fork. ### `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 via (sessionpipe control):` then the text. In `turn` mode the daemon's Stop hook answers `{"decision":"block","reason":}`, 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). 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 `/.sessionpipe/files///` (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`) 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). ### `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 120 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 next hook event of that session (a tool result, a Stop, a new prompt), 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 an API exists (the Agent SDK's interrupt in `sdk` mode; ACP `session/cancel` for a session the daemon holds in `api` mode; OpenCode `/session/:id/abort`; Codex app-server interrupt); everywhere else acked `unsupported`. **Never a signal to a pid.** ### `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_` 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__`) 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 `/.sessionpipe/files//` (the id the command names, which is the session's name everywhere, above), creating `/.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): ``` Attached files (saved in this folder): - .sessionpipe/files//screenshot.png (image/png, 179 KB) - .sessionpipe/files//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. ## 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 the session's next hook event, whichever comes first. A receiver SHOULD show the Allow / Deny pair only while the attention is open. ## 8. The waiter (`sessionpipe wait`) A session that should be reachable in place runs, as a **background** command, `sessionpipe wait --session :`. 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. ## 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?","code?","detail?","reply?","applied?"}]}`. | 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. | | `unsupported` | No path for this kind on this harness. | | `failed` | A path exists and errored; `detail` says what. `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`). 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`, and `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. 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/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. ## 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. --- # Adapters: per-harness mapping > 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/)). An adapter is the only vendor-specific code in sessionpipe: one file under `packages/core/src/adapters/.ts` plus its fixtures under `conformance/harness//`. This document is the registry of harness names and, per harness, where the hooks are written, which native event maps to which protocol event, which ids the payload carries, where the transcript is read for tier 2, and the quirks the code must honour. Every mapping here is proven by a fixture recorded from a real payload; a row without fixtures is marked *(recording pending)* and is not yet normative. **What every adapter reads, and nothing more.** A documented hooks or plugin API, and files the person's own harness wrote on the person's own machine: the transcript named in the hook payload, a session registry, the config file's account id (or the API endpoint its settings name, for a home with no login). No vendor API is called, no credential is read, no token is sent anywhere. The one exception is the account pool ([PRIVACY.md §3](https://sessionpipe.org/protocol/privacy/#3-what-a-sender-never-reads)), which exists only after the person runs `sessionpipe pool on` or `pool add`, holds only the accounts they added, and talks only to that vendor's own endpoints for their own requests; the harness's *Pool* row says exactly what it reads, writes and calls. The documentation URL per harness is in its row. **The one exception, the person's own to make.** A harness whose own service names a session without telling the machine may be asked for that name, and only when the person turned it on (`sessionpipe titles on`, config.json `remote_titles`; off by default). The adapter then reads the harness's sign-in on this machine, sends it to that service alone, for that one read, and keeps it nowhere: never in a file, an event or a log. Only Claude Code has one (claude.ai, below). ## Registry | `harness.name` | Product | Docs | Session end? | Stop can block? | Milestone | |------------------|---------|------|:---:|:---:|:---:| | `claude-code` | Claude Code (Anthropic) | code.claude.com/docs/en/hooks | yes | yes | M2 | | `codex` | Codex CLI (OpenAI) | learn.chatgpt.com/docs/hooks | yes | yes | M2 | | `gemini-cli` | Gemini CLI (Google) | geminicli.com/docs/hooks/reference | yes | yes (AfterAgent) | M2 | | `antigravity` | Antigravity (Google) | antigravity.google/docs/hooks | **no** | no | M2 | | `devin` | Devin CLI (Cognition) | docs.devin.ai/cli/extensibility/hooks/overview | yes | yes (exit 2) | M2 | | `cursor` | Cursor | cursor.com/docs/hooks | yes | yes (`followup_message`) | M7 | | `copilot-cli` | GitHub Copilot CLI | docs.github.com/en/copilot/reference/hooks-reference | yes | yes | M7 | | `droid` | Droid (Factory) | docs.factory.ai/reference/hooks-reference | yes | yes | M7 | | `kiro` | Kiro (Amazon) | kiro.dev/docs/hooks | **no** | yes | M7 | | `opencode` | OpenCode (Anomaly) | opencode.ai/docs/plugins | **no** (`session.deleted` only) | n/a (input API) | M7 | A receiver infers "done" for a harness without a session end from silence, and presents that as its own reading. ## Common conventions - **Config written by `sessionpipe install`** is one entry per event, running `"" "" ` with quoted absolute paths. Never `#!/usr/bin/env node`, never an npx path. - **stdout**: nothing, except where the harness parses it (Antigravity: `{}`). - **`session.seq`** is assigned by the sender's outbox, not by the adapter; adapter fixtures wildcard it. - **Facts** (title, url, model, account_id, repo, branch, attended) are read by the worker from the harness's files after the hook exits; the hook event itself carries only what stdin has. A fixture's `expect` lists what stdin alone yields; `files` in a fixture supplies transcripts for fact tests. - **Ids**: `turn_id` = the harness's prompt/turn id when it has one; `call_id` = its tool-use id; `attention_id` = the tool-use id of the permission request when the payload has one, else an id the sender mints, unique within the session. Claude Code's `PermissionRequest` carries no tool-use id, so its adapter mints `perm-<16 hex of sha256(prompt id | tool | input)>`: the same request always names the same attention, which is what a `permission.answer` has to name (CONTROL.md §6). Notifications without an id are `notif-`. - **Who started a turn** (`turn.started.submitted_by`, `turn.started.injected`): only `claude-code` fills these today, and only when its payload or the prompt's shape says so. `codex`, `gemini-cli` and `devin` leave `submitted_by` absent — no recorded payload of theirs names who submitted the prompt — and leave `injected` absent for want of a recorded wrapper of their own to key on, not because it is unknowable: all three carry the prompt, and Codex's reader already has `codexInjected`. `antigravity`'s `turn.started` has no prompt at all. An absent field is unknown, not "the person" (PROTOCOL.md §4). - **Control** (CONTROL.md §6, §9): only on a machine with control paired, the Stop hook asks the daemon's socket for a queued, verified message and may answer a block with it, and the PermissionRequest hook may wait there for a verified answer. These two are the only hooks that ever print for a harness that doesn't parse stdout, and they are exempt from the 150 ms budget. ## Claude Code (`claude-code`) — M2 | | | |---|---| | Config | `settings.json` in **every** config dir: `~/.claude`, `$CLAUDE_CONFIG_DIR`, and any `~/.claude-*` holding `projects/` or `settings.json` (one per account; hooks written to one never fire for the others). Timeouts in seconds. | | Hook shape | `{"hooks":{"":[{"matcher":"","hooks":[{"type":"command","command":"…","timeout":5}]}]}}`; the matcher is only present on tool and permission events. | | Mapping | `SessionStart` → `session.started` (`source` ← `source` / `trigger`) · `UserPromptSubmit` → `turn.started` (`turn_id` ← `prompt_id`, `prompt_chars`, `submitted_by` ← the payload's `source` forwarded verbatim when it has one, `injected: true` when the prompt opens with one of Claude Code's own wrappers) · `PreToolUse` → `tool.started` · `PostToolUse` → `tool.ended` ok · `PostToolUseFailure` → `tool.ended` !ok · `PermissionRequest` → `attention.needed` permission · `Notification` → `attention.needed` (`permission_prompt` → permission, `idle_prompt` → idle, `elicitation_dialog` → elicitation, others → question) · `Stop` → `turn.ended` stop (`background` ← the length of `background_tasks`, the in-flight background work Claude Code lists, recorded from 2.1.258 on) · `StopFailure` → `turn.ended` error (`error` = the API error class) · `SubagentStart`/`SubagentStop` → `subagent.*` · `PreCompact`/`PostCompact` → `context.compacted` · `SessionEnd` → `session.ended` (`reason`: `clear` · `logout` · `exit` · `prompt_input_exit`→`exit` · other). | | Ids in stdin | `session_id`, `prompt_id`, `cwd`, `transcript_path`, `tool_name`, `tool_use_id`, `agent_id`, `permission_mode`, `hook_event_name`. `UserPromptSubmit.source` is in the documented schema (`user` · `sdk` · `system` · `loop_wakeup` · `schedule_wakeup` · `poll_event`, "payloads may omit it while the field rolls out") and is forwarded as is when a payload has the key; none recorded through 2.1.304 does. | | Env | `CLAUDE_CODE_HOST_SESSION_ID` (`local_…`): the desktop app's id for the session → `session.url` `claude://code/continue/` when no bridge link exists. `CLAUDE_PROJECT_DIR`. `CLAUDE_CODE_SESSION_ATTENDED` (`1` / `0`) and sessionpipe's own `SESSIONPIPE_CONTROL_JOB`, for `session.attended`. | | Transcript (tier 2) | `/projects//.jsonl`; records `type: user\|assistant` with `message.content` a string or text parts, and the person's mid-turn messages: one the running turn absorbs is never a `user` record but `type: attachment` with `attachment.type: queued_command`, `commandMode: prompt` and the text in `attachment.prompt` (a string or text parts); it reaches `turn.transcript` / `turn.progress` as the person's words. Skip `isMeta`, `isSidechain`, non-`human` `origin.kind` (a queued command's is `attachment.origin`, absent in older versions; task notifications and `peer` messages share the record), injected `…` blocks. | | Limits (tier 1) | Only a status line command is handed the account's limits (https://code.claude.com/docs/en/statusline): stdin `rate_limits.five_hour` / `seven_day` / `spend_limit`, each `used_percentage` and `resets_at` (epoch seconds), for Pro and Max (or behind a gateway) and only after the session's first response. A status line hides most of Claude Code's footer hints, so it is opt-in: `install --status-line` writes `statusLine` → the hook with event `StatusLine` and `refreshInterval: 60`, and never replaces one the person set. The hook prints the limits as the line (`5h 23% · week 41%`) and queues a job only when they changed, at most once a minute per config dir; Claude Code runs a status line only on events (an assistant message, a window's reset), so without the timer a change held back that minute would wait for the next prompt; `StatusLine` → `limits.observed`: every window under Claude Code's own key, so one it adds later arrives as its own id; `window_minutes` 300 and 10 080 for `five_hour` and `seven_day` and absent otherwise. `seven_day` is the week across all models. `spend_limit` carries `used_percent` (above 100 once exceeded) and `resets_at`; its `used_usd`, `limit_usd` and `period` are not carried. | | Facts | Title: the per-pid registry `/sessions/.json` (`name` with `nameSource` user → `custom`, auto/hook/peer → `harness`; `derived` is never a title), found through process ancestry because a `--continue --remote-control` session files its entry under another id; then `custom-title` / `ai-title` records; then the first human ask cut to 50 chars (`first-ask`). With `remote_titles` on, a session with a claude.ai link takes claude.ai's name for it (`harness`) over everything but a `custom` title: claude.ai names a session the Claude app started a few seconds after its first message and never writes that name to the machine (none of 177 such sessions on one Mac had it on disk), so without this the Claude app shows one name and every local reader another. It is read the way Claude Code reads a session, `GET https://api.anthropic.com/v1/code/sessions/` (`anthropic-version: 2023-06-01`; the answer is `{response_shape: {id, title, …}}`, recorded in `conformance/remote/claude-code/`, and Claude Code also reads `session`; asked as `session_…` it names the session `cse_…`, the same one; the title is taken only when the id names the session asked about, cleaned as Claude Code cleans a name, and an answer over 1 MiB is not read), bearing the config dir's own sign-in: the macOS keychain item `Claude Code-credentials` (with `-` and the first 8 hex of the SHA-256 of `$CLAUDE_CONFIG_DIR` as it was set, for a config dir it names), else `/.credentials.json`, `claudeAiOauth.accessToken`. A token within a minute of its `expiresAt` is not used and never refreshed (that would rotate Claude Code's own login). A redirect is refused. Asked again a minute later while it has no name, then every half hour, within 3 s (the worker holds the session's lock); a 400, 401 or 404, or no answer, keeps the name it had. Turning it on (`sessionpipe titles on`, again with `titles refresh`) asks about every session of the last 30 days and sends each one already sent under another name again as `session.backfill` with that name; a backfill takes these names too. A rename in the Claude app needs none of this: it reaches the transcript as a `custom-title` record. URL: registry `bridgeSessionId` → `https://claude.ai/code/`, else `bridge_status` / `bridge-session` records. Account: `oauthAccount.accountUuid` in the config dir's `.claude.json` (never the email on an event); control's pair and hello name each config dir's account as `{id: accountUuid, email: emailAddress}`. A config dir with no `oauthAccount` whose `settings.json` sets `env.ANTHROPIC_BASE_URL` to an http(s) host other than Anthropic's (`*.anthropic.com`) runs on another API (Kimi on a Modal endpoint, GLM on Z.ai): its account is `api-` + the first 16 hex of the SHA-256 of that URL's origin (`scheme://host[:port]`; never its path or a `user:password@`), and pair and hello name it `{id, name: host}`. Only that one key is read, never the tokens or headers beside it. Model: last assistant record's `message.model`. Attended: `CLAUDE_CODE_SESSION_ATTENDED=1`, a control turn (`SESSIONPIPE_CONTROL_JOB`), or a link (the bridge link or the desktop app's) → `true`; `0` with none of those → `false`; no variable → absent. | | Quirks | Running sessions pick up hook changes live from 2.1.280. `type: "http"` handler exists (native lane). A prompt with pasted images can put the first ask megabytes into the file: stream to it, cap at 16 MB. Every headless run says `CLAUDE_CODE_SESSION_ATTENDED=0`, a Remote Control session included, and only a terminal says `1` (measured on 2.1.307–2.1.310). A `claude -p` started inside a Remote Control session inherits `CLAUDE_CODE_ENVIRONMENT_KIND=bridge` and the bridge owner's ids, but the registry entry it writes for itself has no `bridgeSessionId`: its link, not its environment, tells a Remote Control session from a probe run inside one. **Who started a turn is only partly knowable.** No payload recorded through 2.1.304 carries `source` at all, so `turn.started.submitted_by` is absent on every build in the field today and `injected` is the only signal. `injected` is a **closed list of Claude Code's own wrappers** — ``, ``, ``, ``, ``, closed or self-closing, after trimming — and deliberately narrower than this spec's own transcript reader (`isInjected`), which calls any `…` block or HTML comment injected. The reader can afford that: dropping one record from a transcript costs little. A `turn.started` cannot, because a receiver acting on `injected` would drop one of the person's own turns whenever they pasted nothing but an SVG, an HTML page or a JSX element. So `injected` **misses**: a peer message delivered as bare text (byte-identical to a person's prompt), a cron or scheduled wake whose prompt is ordinary text, an HTML-comment injection, and any wrapper Claude Code adds after this list. It is also **not** "no person caused this turn": a Talk or `sessionpipe wait` delivery hands the person's own message over inside a `` whose text is an output-file path. The transcript is no substitute: a queued command's `attachment.origin.kind` is absent in older versions, the transcript fixture records a `` with `origin.kind: "human"` and `humanTurn: true` (`conformance/transcripts/claude-code/queued-command.jsonl` line 14), and the hook fires before the record lands. Neither field ever proves a person typed the prompt. | | Pool (CONTROL.md §6 `pool`, PRIVACY.md §3) | **Nothing below happens unless the person runs `sessionpipe pool on` or `pool add` at the machine.** `pool on` writes, into `env` of every config dir's `settings.json` (same rules as the hooks: indent and EOL kept, non-plain JSON skipped, never over an `ANTHROPIC_BASE_URL` the person set), exactly three keys: `ANTHROPIC_BASE_URL` (the daemon's loopback listener, `http://127.0.0.1:`), `ANTHROPIC_AUTH_TOKEN` (a random token the listener checks; never an account's) and `_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL=1` (so Claude Code keeps its first-party features behind a custom base URL; the name is in its binary); `pool off` removes exactly those keys. `pool add --import` reads Claude Code's own login once, to copy it: the macOS keychain item with service `Claude Code-credentials` (suffixed `-<8 hex of sha256(config dir)>` for a config dir other than `~/.claude`), elsewhere `/.credentials.json` (`claudeAiOauth`: `accessToken`, `refreshToken`, `expiresAt`), and the config dir's `.claude.json` for the account id and email, as above; the copy lives in sessionpipe's secrets store and Claude Code's own login is left where it is. `pool add --login` signs an account in the way Claude Code 2.1.312 does: `https://claude.com/cai/oauth/authorize` (PKCE, Claude Code's own client id and scopes; the person pastes `code#state` back from `https://platform.claude.com/oauth/code/callback`), `https://platform.claude.com/v1/oauth/token` for the exchange and later refreshes, and `https://api.anthropic.com/api/oauth/profile` once, for the id and email; every OAuth call carries `anthropic-beta: oauth-2025-04-20`. While the pool is on, the listener forwards each request to `https://api.anthropic.com` with the chosen account's bearer and that beta header, rewrites the account part of `metadata.user_id` (the JSON form of 2.1.x, or the older `user_…_account_…_session_…`) to that account, pins the session (its session part) to it, and reads the `anthropic-ratelimit-unified-*` headers off the response: `status` (`allowed` · `allowed_warning` · `rejected`), `representative-claim` and `reset` for the account, and per window `5h-utilization` / `5h-reset`, `7d-utilization` / `7d-reset` and `7d_-utilization` / `7d_-reset`, which become `limits.observed` with `account_id` on the session that made the request: `five_hour` (`window_minutes` 300), `seven_day` (10 080) and `seven_day_` (no length), `used_percent` from the fraction. Idle accounts are read from `https://api.anthropic.com/api/oauth/usage` (the same windows, utilization a percent already), at most every 5 minutes and at once after `pool add`, `enable` or `refresh`; those numbers reach only the hello's `pool` (`status`, `until`, `windows`) and `pool status`, never an event, since no session made them. The request and response bodies pass through unchanged; nothing of them is kept or logged. `packages/core/src/adapters/claude-code-pool.ts` (`POOL_ENDPOINTS`, `readLimits`, `usageLimits`) is the source of these facts. | | Start options (CONTROL.md §3.1, §6) | `claude -p`, and its resumes and forks: `read` → `--permission-mode plan`, `safe` → `--permission-mode dontAsk` (a build without `dontAsk`: `--permission-mode default`, or `manual` where it has no `default`, plus `--permission-prompts none` where it has that), `edits` → `--permission-mode acceptEdits`, `auto` → `--permission-mode auto`; `model` → `--model `, two arguments. The Agent SDK: `permissionMode` `plan` / `dontAsk` / `acceptEdits` / `auto`, and the `model` option. A level is offered only when `claude --help` lists the mode it maps to (`plan`, `acceptEdits`, `auto`); `safe` always is. Any well-formed model is passed (`any_model`); Claude Code names an unknown one in its error. Never `bypassPermissions`. On an `inherit` machine a start that names no level gets no `--permission-mode` (and the SDK no `permissionMode`), so Claude Code's own `permissions.defaultMode` decides; a start there may name only `read` or `safe`. | Fixtures recorded 2026-09-28 on Claude Code 2.1.258 (one `claude -p` run): `SessionStart`, `UserPromptSubmit`, `PreToolUse` / `PostToolUse` for Bash, Read and Agent (with `duration_ms` and, inside the subagent, `agent_id`), `PermissionRequest` (no `tool_use_id`; carries `permission_suggestions`), `SubagentStart` / `SubagentStop`, `Stop`, `SessionEnd`, `StopFailure` (`error: server_error`, forced with an unreachable API). Still pending real payloads: `PostToolUseFailure`, `Notification`, `PreCompact` / `PostCompact` (a failing `cat` arrived as a `PostToolUse` with the error in `tool_response`). Recorded 2026-10-21 on Claude Code 2.1.304, a background task's notification waking a second turn (`user-prompt-submit-injected.json`): `UserPromptSubmit` with an injected prompt and **no** `source` key. No recorded payload carries that key, so the `submitted_by` mapping is covered by unit tests only, until a build sends one. ## Codex (`codex`) — M2 | | | |---|---| | Config | `~/.codex/hooks.json` (`{"hooks":{…}}`, Claude-shaped, timeouts in seconds; `SessionEnd` and `Interrupt` are clamped to 3 s), **and** `notify` at the **top level** of `config.toml`, before the first `[table]`, as the fallback when hooks are off. An existing `notify` (Codex Computer Use) is chained through `--previous-notify`, never replaced. Non-managed hooks need trust (`/hooks` in the CLI); the installer says so. | | Mapping | `SessionStart` → `session.started` · `UserPromptSubmit` → `turn.started` (`turn_id`) · `PreToolUse`/`PostToolUse` → `tool.*` (`tool_response` a string → ok unless it is an error text the harness marks; Codex has no PostToolUseFailure, so `ok` is true and `error` absent) · `PermissionRequest` → `attention.needed` · `Stop` → `turn.ended` stop · `Interrupt` → `turn.ended` interrupt · `SubagentStart`/`SubagentStop` · `PreCompact`/`PostCompact` · `SessionEnd`; notify `agent-turn-complete` → `turn.ended` when hooks are off. | | Ids in stdin | `session_id`, `turn_id`, `cwd`, `transcript_path`, `model`, `permission_mode`, `tool_name`, `tool_input`, `tool_response`, `tool_use_id` (`exec-`), `stop_hook_active`, `last_assistant_message`, `source`, `reason`. | | Transcript (tier 2) | `transcript_path` = `~/.codex/sessions/YYYY/MM/DD/rollout-