Get started
Markdownllms.txt

sessionpipe protocol v1

On this page

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

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 is the wire binding, PRIVACY.md the tiers and rulesets in detail, CONTROL.md the channel back, ADAPTERS.md the per-harness mapping, VERSIONING.md 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, generated from the reference implementation's types.

{
  "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).
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. A receiver sees gaps: the events a sink's tier leaves out, and the tool events a sender thins (§4); a gap is never a lost event.
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). 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 <harness>:<id> (the form CONTROL.md 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.
delegated_by The session this one answers, as <harness>:<id>: the machine's daemon started or resumed this session for that one's ask, or for a handoff with reply_to (CONTROL.md §6, §13), and set SESSIONPIPE_ASKED_BY in the child's environment; the hook reads it like launched_by, with the same rules (the nearest asker, reset by anything that starts a further session, never the session itself). On every event of the child; absent otherwise. A receiver treats it like parent_id across harnesses: lineage, never authority. It MUST NOT read it as a subagent, and nothing the child says binds the session that asked.

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.

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". A sink at tier 1 or above is sent tool events instead; thinned (§4), a session whose tools keep finishing still sends a tool.ended at least once a window plus the time to the next call that finishes.
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, and it never counts sessionpipe's own waiter (CONTROL.md §8), which ends only when a message arrives. A session whose last turn ended with background > 0 is paused waiting on its own work, not done: a receiver SHOULD show it as working until its next turn.ended or session.ended, and MUST NOT take the work's own events (subagent.*, a subagent's tool events, which arrive before the session wakes) for the session's next turn; a subagent.ended that carries background restates the count, so a receiver MAY show the session as done when that count is 0 and as working otherwise. Absent when the harness doesn't say. A harness may fire no hook at all when the person interrupts a turn (Claude Code); a sender that learns of it from the harness's own files sends the turn.ended with reason: interrupt at the session's next hook, so it can come long after the interruption, and a receiver MUST NOT expect one for every turn.started.
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 rises with seq. A sender MUST NOT send an exchange twice, or in parts: where a harness's hook fires before its file holds the end of the turn, the sender waits for the file. The event follows its turn's turn.ended in seq: next to it, or later when the file is slow, and then possibly after the next turn's turn.started, never after that turn's own turn.transcript. One case puts it before its turn.ended: a sender records a hook when it gets to it, so of two ends the harness fires within milliseconds (a headless run's stop and its session's end) the later may be recorded first; it carries every exchange not yet sent, and the earlier turn.ended follows. A receiver MUST NOT depend on which of an exchange and its turn.ended comes first. An exchange the person interrupted is sent with what had been said, which may be nothing (assistant empty).
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), agent_id (set when a subagent asked) 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 of the thread that asked ran, its turn ended: CONTROL.md §7 says which events those are), or a control answer. No sender emits it yet; a receiver closes an attention by §7's rule.
subagent.started / subagent.ended 1 agent_id, agent_type?; subagent.ended adds background? (integer) Subagent hooks; the subagent's own events carry session.parent_id. background is the work still in flight once this subagent is out of it, counted as turn.ended counts it, so a receiver holding the session as waiting on its work learns the count fell before the session's next turn. Absent when the harness doesn't say.
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.
delegation.started 0 ask_id (ULID), via: ask · handoff, harness (the agent asked), to? (<harness>:<id> of the session that answers, once known); tier 1 adds text (what was asked, ≤ 200 chars, redacted) The machine's daemon took an ask from this session (CONTROL.md §13) or a command with reply_to naming it (CONTROL.md §6). Emitted on the asking session, through its own outbox, with its own seq. to is known at once for a handoff or an ask to an existing session; for a new Codex or ACP session, when the harness reveals its id.
delegation.ended 0 ask_id, to?, ok (boolean), ms, error? (≤ 200 chars, when not ok), relayed?: delivered · unsupported · failed; tier 2 adds reply (≤ 20 000 chars, redacted) The asked agent's turn ended, or the ask failed before one. relayed is a handoff's only: whether the answer reached reply_to. The answering session's own events carry delegated_by; a receiver joins the two on it and on to.
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.

  • Thinned tool events. A harness fires a hook per tool call, and a session that runs a few hundred subagents fires thousands a minute (sessionpipe issue #208: 97 243 events by 10 a.m., 87 % of them subagents' tool calls). A sender MAY thin a sink's tool events, for a subagent's calls (agent_id set; the main thread's all go) or for every call of the session. A thinned thread is sent no tool.started, and of its tool.ended events the first by time that is at least 60 s from the last one the session was sent; the window restarts there. An event a little out of time order falls in the window like any other. Whatever the window says, the sender MUST send a thread's first tool.ended timed at or after its attention.needed (which then carries that agent_id): it is the event that shows the prompt was answered (attention.cleared, above). A sender that thins MUST NOT thin any other type (turns, attention, subagents, files, limits, sessions all go as they come). So a session whose tools keep finishing is seen to work at least once a window plus the time to the next call that finishes. What is thinned is a delivery choice per sink, never what the sender records: it keeps every event. A receiver, which can't tell a thinned sink from another:

    • MUST NOT read a seq gap as a lost event;
    • MUST NOT expect a tool.ended for every tool.started, or the reverse: a call's call_id may be seen once, or not at all;
    • MAY read a session's tool.ended as a sign that it is working, and one of the thread that asked as a sign that its open prompt was answered (CONTROL.md §7: a subagent's tool.ended says nothing about a prompt the main thread opened, nor the main thread's about a subagent's), and MUST NOT present the number it got as the number of tool calls the session made.

    Thinning is the receiver's to ask for, never done on nobody's say-so: a receiver that wants it says tools in its well-known file (HTTP.md §1), and a sender that reads nothing there sends every tool event, so a receiver that reads a subagent's tool calls keeps them across a sender's upgrade. The reference client's setting is the sink's tools: all (every tool event), main (a subagent's thinned), beat (all of them thinned); unset, it is what the receiver's file says, else all.

  • 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 §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 <task-notification>. 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.
    • 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.

What 0 1 2 3
Session id, state events, timing ✓ ✓ ✓ ✓
Machine, cwd, repo, branch, model, title, url, account_id, attended ✓ ✓ ✓ ✓
Attention kind; who asked whom (delegated_by, delegation.* without text) ✓ ✓ ✓ ✓
Tool names, durations, ok/error — ✓ ✓ ✓
File paths touched — ✓ ✓ ✓
Attention message, prompt length, subagents, compactions, what an ask asked (text) — ✓ ✓ ✓
The account's usage limits (limits.observed) — ✓ ✓ ✓
Prompts and assistant text (turn.transcript, turn.progress, a delegation's reply) — — ✓ ✓
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. A tool event a sink is configured not to be sent (§4, thinned tool events) is skipped by that choice, and stays in the outbox.
  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.