sessionpipe

Control: the channel back

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).

Events flow from the machine to the receiver. Control flows the other way: a receiver's page (on a phone, say) answers a permission prompt, queues a message for the agent's next turn, or asks it to stop. 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. Transport

A sender with a live session and a sink that has control: true long-polls GET {control}?session=<harness>:<id>&wait=<s> (HTTP.md §3) and acks each message with POST {control}/ack. A message is retried by the receiver on every poll until acked or expired. Each message is control.json:

{ "id": "01K6…", "kind": "permission.answer", "for": "toolu_01…", "decision": "deny",
  "note": "Not on main, please.", "at": "2026-09-28T02:10:00.000Z", "expires_at": "2026-09-28T02:12:00.000Z" }
Field Rule
id ULID; the ack key.
kind permission.answer · prompt · cancel.
for permission.answer only: the attention_id of the attention.needed event being answered.
decision permission.answer only: allow or deny.
text prompt only: the message for the model, ≤ 20 000 chars.
note Optional; shown to the model beside a decision.
at, expires_at The receiver's clock. After expires_at the sender MUST NOT act and MUST ack expired.

2. Kinds

permission.answer

Answers a pending attention.needed of kind permission. The sender delivers it through the harness's permission hook answer (Claude Code PermissionRequest → hookSpecificOutput.decision; Codex PermissionRequest; Gemini CLI Notification of type ToolPermission has no answer channel, so it is acked unsupported there). An answer that arrives after the hook's own timeout is acked expired: the harness has already asked in the terminal. Nothing is ever auto-allowed: a sender MUST NOT answer allow to a prompt for which no permission.answer with that for was received.

prompt

Text delivered to the agent as a new user message at the next turn boundary. The cross-harness mechanism is the Stop hook's block-with-reason: when the sender's Stop hook runs and a prompt is queued for that session, it answers {"decision":"block","reason":<text>} (Claude Code, Codex, Cursor followup_message, Copilot CLI, Kiro, Droid), and the harness continues the turn with the text as the next instruction. Where the harness has an input API the message goes immediately instead (OpenCode POST /session/:id/message, Codex app-server). A prompt delivered this way is acked delivered with the turn it landed in; one that expires before a turn boundary is acked expired.

cancel

Asks the harness to stop the current turn. Delivered only where an API exists (OpenCode /session/:id/abort, Codex app-server interrupt); everywhere else acked unsupported. Never a signal to a pid.

3. Acks

{"acks":[{"id","outcome","at","detail?"}]} with outcome delivered (the harness took it), expired (past expires_at, or the hook's window closed first), unsupported (this harness has no path for this kind), failed (a path exists and errored; detail says what). Every message is acked exactly once with a final outcome; a receiver MUST tolerate a duplicate ack for the same id.

4. Attention lifecycle

attention.needed (kind permission) → optional 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. A receiver SHOULD show the Allow / Deny pair only while the attention is open and hide it on attention.cleared.

5. Fixtures

conformance/delivery/control-*.json cover: a permission answered in time; one answered after timeout (expired); a prompt delivered at a Stop; a prompt that expires first; a cancel on a harness without an API (unsupported); the same message delivered twice (one action, two acks tolerated).

6. Status

Control is specified in v1 and implemented in milestone M6. Until a sender implements it, it MUST NOT add control: true to a sink, and a receiver that lists control MUST accept polls that return 204 forever.