A session is a durable journal on the server. A connection is an authenticated view of it.
From that one model:
One cursor, <seq>.<index>, is a journal position. It covers a network blip, a process restart and a switch to another worker.
session/prompt answers at durable acceptance. A client commandId makes retries exact and is the messageId.
One fenced writer per root and any number of readers. Agent→client requests go to the writer.
session/close detaches. Work belongs to the journal, not to a socket.
Child transcripts are delivered when asked for; child state always flows through a mirror.
Negotiated with _meta.tantra = {"version": 1}. Two divergences from v2 apply to every client and two more to profile clients only. Everything else is v2 plus _-prefixed extensions.
Every frame in the schema test, and every profile fixture, validates against the alpha.8 schema with an empty divergence allowlist. Benched at 1,000 observers plus 100 turns on two workers.
Opaque per-stream _meta["acp/cursor"]; a bounded in-memory ring buffer behind transport.resumable {retentionMessages, retentionSeconds}; reconnect to the same Acp-Connection-Id; refuse with 409 / stream/resume_failed, then fall back to session/resume. Durable replay logs are rejected as “scope creep”.
Two recovery layers. A restart, a retention miss or a different backend falls back to session/resume, which today means replay from start.
First, “the fix”: updatedAt moves only when the retained conversation changes, never for a connect, a resume or a config write. Second, an optional inclusive cursor, replayFrom: {"type": "message", "messageId"}, behind session.resume.replayFromMessage; a message no longer retained (compaction, rewind) → -32602, then start. Motivation: 27,765 updates replayed from start to catch up about fifty turns.
The cursor names messages only and fails when compaction or rewind dropped the named message.
session/prompt returns messageId at insertion. The v2 prompt RFD’s FAQ calls it “an acknowledgment, not a client-supplied idempotency key”; a client promptId is deferred.
A lost response stays ambiguous; a retry can submit twice.
A proxy multiplexer. Controller/observer roles were removed so events need no per-client filtering. request_permission goes to every client; the first response wins and permission_resolved tells the rest.
Every attached client may answer; there is no designated answerer.
Child events are pushed automatically; clients MUST NOT load or resume a child. Selective subscription is “worth revisiting” if it strands nothing and defines catch-up.
The session is a durable journal on the server; a connection is an authenticated view of it.
The open proposals each address one part, at a different layer: a per-stream buffer (#2208), a message cursor (#2394), a multiplexing proxy (#533).
Each profile rule follows from the model:
Consequence
Rule on the wire
The cursor is a journal position
_meta.tantra.cursor: "<seq>.<index>" on every journal-derived update; replayFrom: {"type": "_tantra_after", "cursor"}
Acceptance is durable
session/prompt answers {messageId} once the input is committed to the journal; for profile clients messageId equals the commandId (a profile-only divergence)
One fenced writer per root
A lease in the store. Readers are any number of connections on any worker.
Asks go to the writer
Agent→client requests go to the writer only. Every notification goes to every connection.
Transport cursor plus an in-memory buffer, connection affinity, retention window; restart → full replay
The cursor is already durable, so there is one layer. No buffer, no retention window, no affinity. Any worker serves any resume, including after the owning worker is killed.
test_profile_cursor_resume_is_served_by_the_other_runtime; test_cursor_resume_at_every_cut_equals_the_uninterrupted_stream; Sarathi e2e owner-death (a backend is killed, the client resumes from its cursor on the other); the bench puts each root’s writer and observer on different workers
#2394
Inclusive messageId cursor that fails when compaction or rewind dropped the named message
An exclusive cursor that names any position, including between non-message updates (#2208’s own argument against messageId). Compaction appends to the journal, so it never invalidates a cursor. An empty replay after the last cursor means nothing changed, so no updatedAt comparison is needed. tantra’s updatedAt does not meet #2394’s definition (see Costs).
test_cursor_resume_at_every_cut_equals_the_uninterrupted_stream (cuts at every output, with compaction, tools, asks and a child); test_hidden_events_leave_cursor_gaps_and_cuts_still_hold
#2286
Proposes a MUST
Done: after every resume response, a state_update snapshot (with the last stop reason when idle) precedes live updates. Profile clients also get the queue, the mirror and, as writer, the pending asks.
messageId is an acknowledgment; a correlation token is deferred
commandId becomes the Runtime command id and the messageId (a profile-only divergence from the Message ID RFD). A retry returns the same messageId. The dedupe record is the journal, so it holds on any worker and across restarts.
Proxy broadcast; roles removed; first permission response wins
A fenced writer. Readers join freely. One routing rule for one message class: requests go to the writer, notifications to everyone. A claim moves pending asks and cancels the old writer’s. submittedBy comes from server auth, is recorded with each input, and is shown in the queue. Native in the agent server; no proxy.
Every child transcript pushed; selective subscription deferred
session/resume on a child, read-only, plus a state mirror. Announcements and state always flow; transcripts only when asked. Cursors and the resume snapshot give catch-up. Nothing strands because tantra children cannot raise asks; a general design would still have to deliver child requests while the transcript is hidden.
Load: 1,000 observers and 100 turns on two workers, with zero reconnects and zero idle journal reads. Numbers are under Implementation details and evidence.
Fewer concepts. One cursor, not a stream cursor plus a protocol cursor. One routing rule. One meaning of close. One id per submission.
Durable by default. It needs no buffer or second store when the harness already keeps an ordered journal, as tantra does. The price is journal reads on resume (see Costs).
Graceful for plain v2 clients. The first mutation claims the writer, children are pushed, and replay is coalesced. Plain clients meet only the two divergences that apply to every client (see Plain v2 clients).
Tenancy stays off the wire. See the next section.
Schema-clean. Extensions use v2’s _ and _meta rules. No frame needs a schema exception.
Tenancy is never on the wire. It lives in three server hooks:
bind: identity, decided at the handshake.
scope: a metadata partition stamped on every new root and used as the session/list filter.
policy: every operation except session/close, checked against server-loaded session headers, never against client claims. session/new is checked against the header of the root it would create.
Unauthorized equals unknown. A policy denial answers -32002 "Session not found", exactly like an unknown id. An existing id the caller may not use answers exactly like an unknown one on every method.
Single tenant (local editor)
Multi tenant (Sarathi)
Transport
stdio; the identity is passed at start
WebSocket: Origin check, then bind(ticket), then accept
scope
{}
{"user": <id>, "kind": "root"}
policy
always True
owner of root and actor; resume with a read or write ticket, everything else needs write on the root; answers re-check the ask’s kind and arguments
Store
memory or SQLite
PostgreSQL plus a coordinator, N workers
Wire, projection, profile, client rules
identical
identical
On a networked deployment, one user on two devices and many users on one server are the same case: several connections, one writer.
The reference TypeScript client speaks WebSocket only; a stdio client follows the same rules.
Embedding hooks (Python API, not wire):
Hook
Contract
bind(asgi_scope) -> AcpAuth
WebSocket only. Runs after the Origin check and before accept. PermissionError, a wrong return type, or an expires_at already past denies.
scope(auth, meta) -> {str: str}
Stamped with acp_cwd on session/new; the subset filter for session/list. PermissionError denies.
policy(access) -> bool
Operations new, resume, prompt, cancel, claim_writer, answer, set_model, delete. Only exactly True allows. new sees a provisional root header (id, root_id, agent, metadata) before anything is created. answer is checked before a request is issued and again with the mapped response.
encode_prompt / decode_input
Content blocks to stored input and back. ValueError → -32602. Default: text only.
view(event) -> event | None
Redaction before projection. None hides the event. Dropping or retyping a ChildCreated hides that child: its subtree leaves the mirror and cannot be resumed, its inputs to the parent project nothing, and the parent’s calls that target it are withheld.
AcpAuth(principal, submitted_by, expires_at)
submitted_by is persisted with inputs, answers and cancels. The WebSocket closes at expires_at.
single = AcpServer(runtime, agent=Assistant, scope=no_scope, policy=allow_all, name="assistant")
The key words MUST, MUST NOT, SHOULD and MAY are used as in RFC 2119. “Agent” is the server; “client” is the connected peer. This section is wire-only and describes the reference implementation today.
JSON-RPC 2.0. The agent handles requests concurrently, so responses MAY arrive out of request order. Batches are supported. $/cancel_request works in both directions.
Session ids are canonical lowercase UUID strings. Any other spelling is an unknown session (-32002).
Extension names: _meta.tantra.*, methods _tantra/*, update kinds and stop reasons _tantra_*. Clients MUST ignore update kinds they do not know.
Error messages are fixed strings. They never carry exception details.
A client opts in with capabilities._meta.tantra.version: 1. Only the JSON integer 1 negotiates; true, 1.0 and "1" do not.
The agent answers every valid initialize with protocolVersion: 2, the capabilities above, info, and authMethods: []. A request without an integer protocolVersion → -32602.
A profile client MUST check that the answer advertises _meta.tantra.version: 1. The reference client fails with profile_unsupported otherwise and does not reconnect.
Clients MUST send initialize before anything else. The agent does not enforce this: until then a connection is a plain v2 client without elicitation.
Profile-only output goes only to profile connections: _tantra_writer, _tantra_queue_update, _tantra_notice and _meta.tantra.cursor.
These v2-legal extension values go to every client: the stop reasons _tantra_finished and _tantra_interrupted, _meta.tantra.ask on state_update, _meta.tantra.finished on session_message and subagent_update, and _meta.tantra.askId on requests.
Without the profile, _meta.tantra.commandId and _meta.tantra.mode are ignored, _tantra_after answers -32602, and _tantra/session/claim_writer answers -32601.
Creates a root in the caller’s scope, tagged with cwd. A denied new answers -32002 and creates nothing. Profile: mode defaults to write; a denied claim deletes the new root and answers -32002. mcpServers is ignored.
Roots in the caller’s scope (and with that cwd, when given), newest created first, 50 per page. nextCursor is opaque. A cursor that is not a root of this agent in the caller’s scope → -32602 "Invalid cursor".
Opens the session on this connection. replayFrom omitted or null: no replay. {"type": "start"}: all history. {"type": "_tantra_after"}: profile only. Any other type → -32602. Resuming an open root replaces it.
session/prompt
sessionId, prompt, _meta.tantra.commandId
{messageId}
Roots only, open on this connection. Needs the writer. Answers once the input is durably accepted. messageId is the commandId, or a fresh UUID without one.
session/cancel
sessionId, _meta.tantra.commandId (notification)
—
Cancels the running turn and every queued input in the tree. A session not open on this connection is ignored.
session/close
sessionId
{}
Detaches this connection’s view: stops its tails, cancels its outstanding requests, releases its writer. Never cancels work.
session/delete
sessionId
{}
Deletes the tree, including active work. Needs neither an open session nor the writer.
session/set_config_option
sessionId, configId: "model", type: "id", value
{configOptions}
Only model, only to a configured value. Needs neither an open session nor the writer. Refused while coordinated work is active (model_busy).
_tantra/session/claim_writer
sessionId
{}
Profile only. See Writer claim.
prompt blocks MUST each carry a string type. Which types the agent accepts is server configuration (default: text); a rejected block → -32602.
session/new, session/resume, session/prompt, session/cancel, claims, answers, session/set_config_option and session/delete are checked by policy. A denied request answers -32002. A denied cancel is dropped without a notice, and a denied answer is not applied. session/close needs no check: it only affects this connection.
replayed updates → response → session_info_update → state_update snapshot (plain clients: also every pushed child’s subagent_update and state_update) → profile: _tantra_queue_update and the mirror snapshot → writer: _tantra_writer claimed (profile) → live updates. A writer’s pending-ask requests follow claimed and MAY interleave with live updates.
session/resume on a child
replayed updates → {} → state_update snapshot → live updates
session/prompt
{messageId} after acceptance. The input’s _tantra_queue_update, user_message and state_update running come from the journal and MAY arrive before or after it.
_tantra/session/claim_writer
{} → _tantra_writer claimed → pending-ask requests, which MAY interleave with live updates
Snapshots carry no cursor. Live updates of a session never precede its snapshots.
Each journal event becomes zero or more updates on the session that owns the event.
Journal event
Update
InputQueued (root user input)
none; profile: _tantra_queue_update
TurnStarted, user input
user_message {messageId: commandId, content}, then state_update running
TurnStarted, input from a parent or child agent
incoming session_message {messageId, senderSessionId, recipientSessionId, content}, with _meta.tantra.finished: true for a child’s finish; nothing for a child’s status-only “turn ended” input, and nothing for any input from a child that view hid. Then state_update running (agent-initiated work).
TextDelta / ReasoningDelta
agent_message_chunk / agent_thought_chunk, messageId = sample id
TextPart
agent_message {content: [text]}, an upsert that replaces the chunks
ReasoningPart
agent_thought {content: [every reasoning block of the sample so far]}
tool_call_update {status: "completed" or "failed", rawOutput}, then a tool_call_content_chunk with the result when it is not empty
spawn, send, finish calls
Not tool calls. Outgoing session_message: spawn at ChildCreated, send and finish on success. A failed one is a failed tool_call_update. Every update of a spawn, send or status call that targets a child hidden by view is withheld; its index is kept.
ChildCreated
subagent_update {sessionId, title} (the child’s name, else its agent type), before any child traffic
AskRaised
state_update requires_action with _meta.tantra.ask {askId, state: "pending"}; the writer also gets a request (see Writer and asks)
AskAnswered
one state_update with _meta.tantra.ask {askId, state: "answered"}: running, or requires_action while other asks are pending
SampleCompleted
usage_update {used, size}; omitted when the model’s context window is unknown
CompactionApplied
compaction_update {compactionId, status: "completed", summary: []}, then compaction_summary_chunk when there is a summary
TurnCompleted
state_update idle with end_turn, max_turn_requests (step limit) or _tantra_finished (finish)
TurnCancelled / TurnFailed / TurnInterrupted
state_update idle with cancelled / error and error {code: -32603, message} / _tantra_interrupted
End of a queued input that never started
none; profile: _tantra_queue_update
Anything else
none
Ids are stable across replays, reconnects and workers:
user_message.messageId is the command id, so it equals the prompt response’s messageId.
toolCallId = uuid5(sessionId, "<sampleId>:<callId>"). An outgoing session_message uses the same key under the sender’s id.
A turn’s end withdraws outstanding requests for its asks.
Replay for plain v2 clients is coalesced: deltas fold into one upsert per message, and only the latest usage_update is sent. Profile replay is uncoalesced: it is exactly the journal-derived live stream after the cursor.
Unknown, unauthorized, or not open on this connection; a child not open here; a denied claim or new
-32002
Session not found
—
Invalid params; a mutation or mode: "write" on a child open on this connection (a child resume: under a root open here, once policy allows); rejected prompt content; a bad cursor or replayFrom; an invalid commandId or mode; too many open sessions
-32602
fixed per case
—
commandId reused with different input or a different submitter
-32602
Command conflict
command_conflict
A mutation from a connection that is not the writer
-32603
Writer required
writer_required
Coordinator or lease unavailable. A prompt is first tried 4 times under the same command id.
-32603
Service unavailable
unavailable
A model change while coordinated work is active
-32603
Model busy
model_busy
Unknown method, or _tantra/session/claim_writer without the profile
-32601
Method not found
—
$/cancel_request while the handler is still running
-32800
Request cancelled
—
Anything else
-32603
Internal error
—
{
"jsonrpc": "2.0",
"id": 3,
"error": {
"code": -32602,
"message": "Command conflict",
"data": {
"tantra": {
"code": "command_conflict"
}
}
}
}
{
"jsonrpc": "2.0",
"id": 4,
"error": {
"code": -32603,
"message": "Writer required",
"data": {
"tantra": {
"code": "writer_required"
}
}
}
}
Clients SHOULD branch on data.tantra.code first, then on code.
A $/cancel_request for session/prompt that arrives once acceptance has started does not abort it: the request still answers {messageId}.
_meta.tantra.commandId on session/prompt and session/cancel MUST be a canonical lowercase UUID. Anything else → -32602 on a prompt; on a cancel it is logged and the cancel is dropped.
It becomes the Runtime command id, unique within the tree. For a prompt it is also the messageId, which diverges from v2 (see Divergences from v2).
The same id with the same input from the same submitter is a duplicate: one turn, the same messageId, on any worker.
The same id with a different input or submitter → command_conflict. On a cancel, that is a command_conflict notice.
Every update derived from the journal carries _meta.tantra.cursor: "<seq>.<index>", merged into any existing _meta.
seq is the journal sequence number of the event, in the journal of the session the update is addressed to.
index is the position among that event’s outputs. Internal signals take indices too, so gaps are normal.
Within one resume, cursors on a session strictly increase.
Live-only output carries no cursor: requests, _tantra_* updates, session_info_update, config_option_update, the state_update snapshots and the mirror’s subagent_updates.
A cursor is valid while the projection is unchanged: the same profile version and the same server configuration.
replayFrom: {"type": "_tantra_after", "cursor"} replays every journal-derived update strictly after the cursor (exclusive, like Last-Event-ID), then the snapshots, then the live tail.
The cursor MUST match (0|[1-9][0-9]*)\.(0|[1-9][0-9]*), and its seq MUST NOT be beyond the session’s last event. Otherwise → -32602.
The live prefix up to a cursor plus the replay after it equals the uninterrupted stream.
{"type": "start"} stays available to profile clients and replays uncoalesced, with cursors.
The session MUST be open on this connection. An open child → -32602; anything else → -32002.
If this connection already writes, the claim is a no-op that answers {}.
Otherwise policy decides (denied → -32002). The current writer is fenced: its outstanding requests get $/cancel_request and, if it was told claimed, it receives _tantra_writer replaced.
After {}: _tantra_writer claimed, then every pending ask is re-issued here. Those requests MAY interleave with live updates.
_tantra_notice {level: "warning", code, message} is live-only, uncursored, and sent on the root.
A failed session/cancel is reported this way, because a notification cannot return an error. A cancel denied by policy, or for a session not open here, produces no notice.
writer_required: this connection is not the writer; nothing was cancelled.
command_conflict: the commandId was used for something else.
unavailable: still failing after 4 attempts.
session_deleted: the root was deleted, by any other connection on any worker or by the application (Runtime.delete, Runtime.cleanup). Sent once to every profile connection with the root open, once its session/new or session/resume has answered; the agent then closes the root and its open children there. A connection’s own successful session/delete produces none; if that request errors or is cancelled after the deletion committed, the connection MAY still get it. Clients SHOULD drop the session and its descendants.
session/resume on a root already open on this connection replaces it.
Its open children close; session/close on one then answers -32002.
The writer follows the new mode: write claims again and sends claimed; read releases it without a _tantra_writer update.
A rejected replayFrom leaves the open session untouched.
write claims before the open session is replaced:
A claim that is denied, or fails or is cancelled before it takes effect, leaves the open session untouched, including its writer.
A claim that takes effect but whose completion is lost (a timeout, or $/cancel_request mid-claim) fails the request, and the open session is fenced: it gets _tantra_writer replaced.
Once the claim succeeds, the swap completes and answers even if $/cancel_request arrives. The replaced session never sends replaced. A prompt racing the swap on it MAY fail with writer_required; a retry with the same commandId is exactly-once.
If the replay then fails (for example on store I/O), the request fails, the session is closed and no writer is left, as after a failed read re-resume.
For profile clients, every descendant is mirrored as subagent_update {sessionId, title, state} on its parent’s session id, parents first. A finished child adds _meta.tantra.finished: true.
A snapshot follows session/new and session/resume; after that, an update whenever the runtime’s tree watch reports a change. The watch keeps only the latest tree, so a short-lived state can be skipped, and a new child’s state may arrive before the parent’s cursored announcement. Live-only.
Child state maps to v2 state:
Child
state
queued, running
running
awaiting input
requires_action
idle
idle with its last stop reason
finished
idle, _tantra_finished
failed
idle, error with error {code: -32603, message}
cancelled
idle, cancelled
interrupted
idle, _tantra_interrupted
The parent’s transcript also carries the journal-derived subagent_update {sessionId, title}, with a cursor, at ChildCreated.
session/prompt answers only after insertion; holding input for later insertion is not acceptance
Answers on durable acceptance (the input is committed to the journal) with {messageId}. user_message is still emitted at insertion.
Every client
Acceptance is a durable commitment. Holding a request for minutes behind a queue makes lost responses likely. v2 clients already tolerate updates before or after the response.
“The Agent MUST cancel any ongoing work for that session”
Detaches this connection only. Cancelling is explicit.
Every client
Sessions are durable and shared. One viewer leaving must not stop work others started or watch.
Clients MUST NOT load or resume a child
Profile clients MAY resume a child read-only, and close it. Plain v2 clients get the standard automatic push.
Profile clients
Pushing every transcript costs in proportion to the tree; cursors give catch-up. Nothing strands only because tantra children cannot ask; a general design would still have to deliver child requests while the transcript is hidden.
“Only the Agent generates protocol message IDs” (Message ID RFD)
A prompt’s messageId is the client’s commandId. The agent validates it (canonical UUID) and rejects reuse with different content (command_conflict). Plain v2 clients get agent-generated ids.
Profile clients
Recovering a lost response needs an id the client knows before sending. One submission is one turn, so the correlation token and the message id coincide (see FAQ).
One writer per root at a time. The writer holds a lease in the store; a claim fences the previous one. A fenced writer keeps reading from its last applied event.
Needs the writer:session/prompt, session/cancel, answering an ask. Nothing else does; policy still applies.
Profile clients claim with mode: "write" or _tantra/session/claim_writer. A reader’s prompt answers writer_required every time; its cancel yields a writer_required notice.
Plain v2 clients claim with their first prompt or cancel. After being fenced, the next prompt fails once with writer_required and the one after reclaims. A cancel always claims.
Asks:
Only the writer receives agent→client requests. Every notification goes to every connection.
An approval becomes session/request_permission {sessionId, title, description?, subject?, options, _meta.tantra.askId}. subject is {type: "tool_call", toolCall: {toolCallId}} when the ask belongs to a tool call. options are allow (allow_once) and deny (reject_once).
A choice or free-text ask becomes elicitation/create {sessionId, mode: "form", message, requestedSchema, toolCallId?, _meta.tantra.askId}, with one required property, choice (a string, with an enum when the choices are fixed) or text. It is sent only to a writer that advertised elicitation.form; otherwise it waits for another writer.
Accepted answers: {outcome: {outcome: "selected", optionId: "allow" | "deny"}}, {action: "accept", content: {choice}} with an allowed choice, or {action: "accept", content: {text}}. Anything else, including an error response, answers nothing, and so does an answer denied by policy. The ask stays pending, and this connection does not get the request again until it next becomes writer, for example by re-resuming with mode: "write". A claim while already writer re-issues nothing.
Policy is checked before the request is issued and again before the answer is applied.
When the ask is answered elsewhere, its turn ends, the writer changes, or the session closes, the holder gets $/cancel_request.
On a claim, every pending ask is re-issued to the new writer.
Readers see state_update requires_action with _meta.tantra.ask, never the request.
tantra children cannot raise asks (a runtime rule), so a child never holds a request.
Plain v2 clients: every descendant transcript is pushed on the same connection, announced by subagent_update before its traffic. Child state is mirrored on the parent’s subagent_update.state. Resuming a child pushed on this connection answers -32602; any other child id answers -32002.
Profile clients: lazy children plus the child mirror, as above.
Messages:spawn, send and finish appear as two-way session_messages on both sessions.
Cancel covers the whole tree. There is no per-child cancel, so subagent_update carries no capabilities.
Newline-delimited JSON-RPC in UTF-8: one message or batch per line. Blank lines are ignored.
The agent’s stdout carries only protocol frames.
A line over 1 MiB ends the connection.
The local process is trusted; its identity is fixed at start.
WebSocket
One message or batch per text frame. Binary frames are ignored.
Clients MUST NOT offer a subprotocol; the agent selects none. Versioning happens in initialize.
Handshake order: Origin, then authentication, then accept.
Origin: exactly one Origin header, matched exactly against an allowlist. A missing Origin is denied unless the server allows it.
Every denial closes before accept, and a bad Origin never reaches authentication. On uvicorn the client sees HTTP 403.
Close codes:
Code
When
1008
Before accept: Origin denied or missing, or authentication denied. After accept: the credential expired.
1009
An inbound text frame over 1 MiB of UTF-8
1011
Authentication failed unexpectedly, or the agent closed the connection: a session stream or the mirror failed, or the runtime stopped
1012
Server shutdown
1013
An outbound send did not finish within 10 s (best effort)
Limits: 1 MiB inbound frames, 10 s per outbound send, and 8 open sessions per connection (-32602). Plain v2 connections count roots; profile connections count roots plus open children.
A durable, ordered journal is required. The memory store suits tests and one process.
Close is not cancel. A generic v2 client that relies on close cancelling leaves work running. It must call session/cancel.
Answering at acceptance: an accepted input cancelled before it starts never produces a user_message. Profile clients see it leave the queue; plain clients see nothing.
Cursors depend on the projection. A change of profile version, server configuration or projection code can shift indices, and nothing on the wire detects it. A server that changes its projection SHOULD bump the profile version.
Resume cost on the server. Every resume reads the session’s journal from the start to rebuild projection state, even when only the gap is sent. A start replay costs time in proportion to history on both sides.
Cancel is coarse.session/cancel stops the whole tree, queued inputs included. There is no per-child cancel, and queued inputs have no steer, revoke or replace.
Approving from a second device means taking the writer. Under #533 a phone or dashboard can answer a permission request and the terminal is unaffected. Here the phone must claim, which fences the terminal: the turn keeps running, but the terminal must reclaim before it can prompt or answer again.
A dismissed request is gone for that connection. An unmapped, declined or policy-denied answer leaves the ask pending; it comes back only when a connection next becomes writer.
Children cannot raise asks in tantra. #1992 allows child permission and elicitation requests; lazy delivery would then have to route them while the child’s transcript is not open.
updatedAt moves on every journal append and header patch, including a model change through session/set_config_option. It does not meet #2394’s proposed definition. Profile clients can resume with their last cursor instead: an empty replay means nothing changed.
Fencing assumes the agent owns the session, as a server-run harness does. A stdio CLI agent still needs a proxy to serve several clients. Few v2 clients exist today, and fencing may stay tantra-only.
A plain v2 client that resumes a session with a pending ask gets no request until its first mutation claims the writer.
Deletion is silent to plain v2 clients. Their views stop, and later calls answer -32002. Profile clients get session_deleted.
Read access is not revalidated after session/resume. A reader keeps receiving updates until it closes the session or the WebSocket closes at expires_at.
The child mirror is coalesced. It follows the runtime’s tree watch, which reads headers on each change and keeps only the latest tree, so a short-lived child state can be skipped.
No third-party ACP SDK in either language. The JSON-RPC layer, transports and wire models are tantra’s own.
The alpha.8 schema.json and schema.unstable.json are vendored as a test reference. A structural validator checks every update kind, request and response the schema test drives, and every example in this document, against them.
Tests
Claim
Tests
Cursor resume at every cut equals the live stream, for a root and a child
the cursor advances only after onUpdate resolves, a prompt lost with the connection is held until the writer is reclaimed, a retried prompt that gets writer_required is held again, reconnect resumes open sessions in order, read-only, from their cursors, a server that drops every connection right after open fails after 10 dials, the session_deleted fixture drops the tree, reports not_found, then reaches onUpdate
Sarathi’s end-to-end suite runs two backends behind Nginx: observing across backends, fencing and reclaim, answering an ask after a takeover, owner death, and recovering a lost prompt response.
Bench (python -m stress.bench.acp): two uvicorn workers on PostgreSQL, 1,000 observers idle for 35 s, then 100 concurrent turns with 10 slow readers. Observers are split across both workers; each root’s writer runs on the other worker from its observer. One connection per root.
Previous custom protocol
ACP profile
Prompt-to-observed-idle p50 / p95, whole run
3312 / 4486 ms
3344 / 4557 ms
35 s idle per worker: CPU, SQL calls
~4.5 s, 848
~3.3 s, 348
Idle journal or event-body SQL
0
0
Active per worker: CPU, journal queries
~4.3 s, 1523
~4.4 s, 1482
Full run: 57 s wall, 1,100 dials, 0 client errors, 0 reconnects, no server closes, 100 journals verified, every resource back to 0, inbound backlog peak 4.
Known gaps in the reference implementation
Journals written before the runtime recorded input senders are classified by the text prefixes of their lifecycle inputs.
With a journal, the cursor is already durable. A buffer is a second copy with its own retention window.
The buffer misses the cases that hurt most: a process restart, another backend, a long disconnect. Each falls back to session/resume, today from start.
Connection affinity ties recovery to one process. A journal cursor works on any worker.
#533 sends every request to every client; first-writer-wins plus permission_resolved settles it deterministically. Any client can answer, which is its core use case: approve from a phone while the terminal keeps working.
Routing requests to the writer is one rule for one message class. Notifications still go to everyone, so no per-event filtering is needed; that filtering was the concern that removed roles from #533.
Handover is explicit: a claim moves the pending asks and cancels the old writer’s requests.
The trade-off: approving from a second device means claiming the writer, which fences the first until it reclaims.
In tantra, acceptance is a durable commit, and insertion can come minutes later behind a queue. A request held that long is the one most likely to lose its response.
The answer promises more than v2’s: the input survives a crash.
user_message still marks insertion, under the same messageId.
Upstream says no. The Message ID RFD: “Only the Agent generates protocol message IDs.” The v2 prompt RFD keeps its deferred correlation token “separate from letting the client choose the canonical messageId”.
The profile diverges anyway, for profile clients only:
Recovering a lost response needs an id the client knows before sending.
In tantra one submission is one turn, so a separate token would always map one-to-one onto the message id. The prompt RFD lists that mapping and the second identifier as the token’s costs; merging them removes both.
The agent still owns validity: it accepts only canonical UUIDs, scopes them to one tree, and rejects reuse with different content (command_conflict).
2026-10-15: Initial draft, matching the reference implementation and the upstream threads as of this date.
2026-10-16: Policy sees new; session_deleted notice; existing ids the caller may not use answer like unknown ones; a failed write re-resume keeps the open session; children hidden by view leak no inputs.