16 KiB
Session Protocol Unification v2 — Draft
Status: DRAFT
Overview
This document captures the proposed unified session protocol event types, incorporating feedback on top of the original session-protocol-unification.md plan. The goal is the same — all agents emit a single envelope format, all legacy senders die — but the event type surface has been redesigned.
For the migration mechanics (phases, task lists, app-side cleanup), see the original plan (stashed as session-protocol-unification.md).
Envelope (unchanged)
{
"id": "<cuid2>",
"time": 1739347200000,
"role": "user" | "agent",
"turn": "<cuid2>",
"subagent": "<cuid2>",
"ev": { "t": "...", ... }
}
| Field | Type | Description |
|---|---|---|
id |
cuid2 | Globally unique message identifier |
time |
number | Unix timestamp in milliseconds |
role |
"user" | "agent" |
Who produced this event |
turn |
cuid2? | Turn id established by turn-start. Required on all agent messages during a turn |
subagent |
cuid2? | Subagent identifier — matches agentId from subagent-start |
ev |
object | Event body, discriminated by ev.t |
Event Types (14)
Content
text — user or agent
{ "t": "text", "text": "Hello, how can I help?", "thinking": true }
| Field | Type | Description |
|---|---|---|
text |
string | Message text (markdown) |
thinking |
boolean? | Agent only. true for internal reasoning |
steer |
boolean? | User only. Mid-turn injection — picked up between LLM calls. Default user messages queue for next turn |
Client-side ordering note: steer messages are displayed immediately but the agent hasn't picked them up yet. The app pins them below current agent output until turn-end or until the agent emits output that follows the steer. No protocol-level ordering needed — client display logic handles this.
file — user or agent
{ "t": "file", "ref": "upload_def", "name": "report.pdf", "size": 524288, "image": { "width": 800, "height": 600, "thumbhash": "..." } }
| Field | Type | Description |
|---|---|---|
ref |
string | Server upload ID |
name |
string | Display filename |
size |
number | File size in bytes |
image |
object? | Optional image metadata |
image.width |
number | Image width in pixels |
image.height |
number | Image height in pixels |
image.thumbhash |
string | Base64-encoded ThumbHash for instant placeholder |
Turn Lifecycle
turn-start — agent
{ "t": "turn-start" }
Establishes the turn id on the envelope. Also serves as the "agent picked up your message" signal — the gap between user text (sent) and turn-start is the "queued" window.
turn-end — agent
{ "t": "turn-end", "status": "completed", "usage": { "inputTokens": 2400, "outputTokens": 180, "cost": 0.012 } }
| Field | Type | Description |
|---|---|---|
status |
"completed" | "failed" | "cancelled" |
Final turn outcome |
usage |
object? | Token usage for this turn |
usage.inputTokens |
number | Input tokens consumed |
usage.outputTokens |
number | Output tokens generated |
usage.cost |
number | Cost in USD |
Replaces the ephemeral usage-report socket event. Also replaces the legacy ready signal — turn-end IS the ready signal.
Tool Calls
tool-call-start — agent
{ "t": "tool-call-start", "call": "tc_abc", "name": "grep", "title": "Searching for handleClick", "description": "Searching for `handleClick` in **src/**", "args": { "pattern": "handleClick" } }
| Field | Type | Description |
|---|---|---|
call |
string | Tool call identifier, matched by tool-call-end |
name |
string | Tool name (lowercase, hyphenated) |
title |
string | Short summary (inline markdown) |
description |
string | Full description (inline markdown) |
args |
object | Tool input arguments |
tool-call-end — agent
{ "t": "tool-call-end", "call": "tc_abc" }
Service
service — agent
{ "t": "service", "text": "Context window compacted" }
System/internal messages — reconnecting, context compacted, process errors. Not from the LLM.
Subagent
subagent-start — agent
{ "t": "subagent-start", "agentId": "sa_abc123", "title": "Research agent" }
| Field | Type | Description |
|---|---|---|
agentId |
string | Subagent identifier — same value used in envelope subagent field on all messages this subagent produces |
title |
string? | Human-readable label |
subagent-stop — agent
{ "t": "subagent-stop", "agentId": "sa_abc123" }
Session Control
session-control-changed — user or agent
{ "t": "session-control-changed", "clientType": "phone", "tokenId": "tok_abc123" }
| Field | Type | Description |
|---|---|---|
clientType |
"cli" | "phone" | "browser" | "daemon" |
What took control |
tokenId |
string? | Auth token identifying the device/session. Used for push notification routing — don't send to phone if browser has control |
Controlling device is also written to session state (metadata) as a rollup of the stream.
Permissions
permission-request — agent
{
"t": "permission-request",
"call": "tc_abc",
"toolName": "bash",
"title": "Run `rm -rf node_modules`",
"description": "Delete node_modules directory",
"args": { "command": "rm -rf node_modules" }
}
Always during a turn. Appears between tool-call-start and tool-call-end. The tool does not execute until the user responds. RPC still delivers in real-time, but the message is the permanent record in the stream.
permission-response — user
{ "t": "permission-response", "call": "tc_abc", "decision": "allow-once" }
Matches the call from the request.
Abort
abort — user
{ "t": "abort" }
Written to the stream when user presses stop. RPC still does real-time delivery. Agent responds with turn-end { status: "cancelled" }.
Configuration
agent-configuration-changed — user or agent
{ "t": "agent-configuration-changed", "model": "claude-sonnet-4-6", "thinkingLevel": "high" }
All fields optional — include only what changed:
| Field | Type | Description |
|---|---|---|
permissionMode |
string? | "default" | "acceptEdits" | "bypassPermissions" | "plan" |
model |
string? | Model identifier |
thinkingLevel |
string? | Thinking budget level |
sandbox |
boolean? | Sandbox enabled |
User sends this when changing config from the app. Agent sends this when entering plan mode, etc. The actual config mechanism stays in session metadata (optimistic concurrency) — this event is the audit trail in the stream so you can scroll back and see "model was switched to Sonnet here."
Event Type Summary
| Category | ev.t |
Role | New? |
|---|---|---|---|
| Content | text |
user/agent | modified (added steer) |
| Content | file |
user/agent | existing |
| Turn | turn-start |
agent | existing |
| Turn | turn-end |
agent | modified (added usage) |
| Tool calls | tool-call-start |
agent | existing |
| Tool calls | tool-call-end |
agent | existing |
| Service | service |
agent | existing |
| Subagent | subagent-start |
agent | renamed, added agentId |
| Subagent | subagent-stop |
agent | renamed, added agentId |
| Session | session-control-changed |
user/agent | new |
| Permissions | permission-request |
agent | new |
| Permissions | permission-response |
user | new |
| Abort | abort |
user | new |
| Config | agent-configuration-changed |
user/agent | new |
Removed
| What | Why |
|---|---|
ready signal (sendSessionEvent({ type: 'ready' })) |
Redundant with turn-end |
start / stop event names |
Renamed to subagent-start / subagent-stop |
machineId on control-changed |
Replaced by tokenId — auth token already identifies the device |
Ephemeral usage-report socket event |
Moved into turn-end.usage |
Realistic Flow
User opens session on phone, agent runs on CLI, user steers mid-turn, switches model, aborts.
// CLI starts, takes control
← { id: "m1", time: 1000, role: "agent", ev: { t: "session-control-changed", clientType: "cli", tokenId: "tok_laptop" } }
// User sends prompt from phone
← { id: "m2", time: 2000, role: "user", ev: { t: "text", text: "Find all TODO comments and fix the critical ones" } }
// Agent picks it up
← { id: "m3", time: 2500, role: "agent", turn: "t1", ev: { t: "turn-start" } }
← { id: "m4", time: 2501, role: "agent", turn: "t1", ev: { t: "text", text: "I'll search for TODOs first." } }
← { id: "m5", time: 2502, role: "agent", turn: "t1", ev: { t: "tool-call-start", call: "c1", name: "grep", title: "Searching for TODO", description: "Searching for `TODO` in **src/**", args: { "pattern": "TODO", "path": "src/" } } }
← { id: "m6", time: 2503, role: "agent", turn: "t1", ev: { t: "tool-call-end", call: "c1" } }
← { id: "m7", time: 2504, role: "agent", turn: "t1", ev: { t: "text", text: "Found 12 TODOs. Starting with the auth module..." } }
// User steers mid-turn — agent hasn't seen this yet
← { id: "m8", time: 2600, role: "user", ev: { t: "text", text: "Skip auth, focus on the payment module instead", steer: true } }
// Agent continues current LLM call, then picks up steer
← { id: "m9", time: 2700, role: "agent", turn: "t1", ev: { t: "tool-call-start", call: "c2", name: "edit", title: "Editing **src/payment/checkout.ts**", description: "Fixing TODO in payment module", args: { "file": "src/payment/checkout.ts" } } }
// Agent needs permission for a destructive edit
← { id: "m10", time: 2701, role: "agent", turn: "t1", ev: { t: "permission-request", call: "c2", toolName: "edit", title: "Editing **src/payment/checkout.ts**", description: "Replacing TODO with retry logic", args: { "file": "src/payment/checkout.ts" } } }
// User approves
← { id: "m11", time: 3000, role: "user", ev: { t: "permission-response", call: "c2", decision: "allow-once" } }
// Tool executes
← { id: "m12", time: 3001, role: "agent", turn: "t1", ev: { t: "tool-call-end", call: "c2" } }
// Agent spawns a subagent to research the second TODO
← { id: "m13", time: 3002, role: "agent", turn: "t1", ev: { t: "subagent-start", agentId: "sa_research", title: "Payment research" } }
← { id: "m14", time: 3003, role: "agent", turn: "t1", subagent: "sa_research", ev: { t: "text", text: "Looking at payment retry patterns..." } }
← { id: "m15", time: 3004, role: "agent", turn: "t1", subagent: "sa_research", ev: { t: "tool-call-start", call: "c3", name: "grep", title: "Searching for retry", description: "Searching for retry patterns", args: { "pattern": "retry" } } }
← { id: "m16", time: 3005, role: "agent", turn: "t1", subagent: "sa_research", ev: { t: "tool-call-end", call: "c3" } }
← { id: "m17", time: 3006, role: "agent", turn: "t1", subagent: "sa_research", ev: { t: "text", text: "Found existing retry utility." } }
← { id: "m18", time: 3007, role: "agent", turn: "t1", ev: { t: "subagent-stop", agentId: "sa_research" } }
// Turn completes with usage
← { id: "m19", time: 3008, role: "agent", turn: "t1", ev: { t: "text", text: "Fixed 2 TODOs in the payment module." } }
← { id: "m20", time: 3009, role: "agent", turn: "t1", ev: { t: "turn-end", status: "completed", usage: { inputTokens: 4800, outputTokens: 620, cost: 0.024 } } }
// User switches model from phone
← { id: "m21", time: 5000, role: "user", ev: { t: "agent-configuration-changed", model: "claude-sonnet-4-6", thinkingLevel: "low" } }
// User sends next prompt
← { id: "m22", time: 5001, role: "user", ev: { t: "text", text: "Now run the tests" } }
← { id: "m23", time: 5100, role: "agent", turn: "t2", ev: { t: "turn-start" } }
← { id: "m24", time: 5101, role: "agent", turn: "t2", ev: { t: "tool-call-start", call: "c4", name: "bash", title: "Run `npm test`", description: "Running test suite", args: { "command": "npm test" } } }
// User aborts
← { id: "m25", time: 5200, role: "user", ev: { t: "abort" } }
← { id: "m26", time: 5201, role: "agent", turn: "t2", ev: { t: "tool-call-end", call: "c4" } }
← { id: "m27", time: 5202, role: "agent", turn: "t2", ev: { t: "turn-end", status: "cancelled" } }
// User opens session from browser — control shifts, push notifications reroute
← { id: "m28", time: 8000, role: "user", ev: { t: "session-control-changed", clientType: "browser", tokenId: "tok_browser" } }
What Stays on Side Channels
| Thing | Mechanism | Why not in stream |
|---|---|---|
| Activity/presence | Ephemeral session-alive / session-end socket events |
Too frequent, not worth persisting |
| Config source of truth | Session metadata with optimistic concurrency | agent-configuration-changed is the audit trail, metadata is the mechanism |
| Permission delivery | RPC for real-time, stream for audit | RPC needed for immediate response, stream needed for history |
| Abort delivery | RPC for real-time, stream for audit | Same pattern |
Migration — What Needs to Change
CLI legacy senders to remove
| Method | Current wire shape | Used by | Action |
|---|---|---|---|
sendAgentMessage(provider, body) |
{ role: 'agent', content: { type: 'acp', provider, data } } |
runGemini.ts (~25 call sites) |
Replace with AcpSessionManager → sendSessionProtocolMessage() |
sendCodexMessage(body) |
{ role: 'agent', content: { type: 'codex', data } } |
Dead code — Codex already on envelopes | Delete |
sendSessionEvent(event) |
{ role: 'agent', content: { type: 'event', data } } |
All runners: ready, errors, mode switch | Delete — turn-end replaces ready, service replaces error messages |
ACPMessageData type |
— | apiSession.ts |
Delete with sendAgentMessage() |
ACPProvider type |
— | apiSession.ts |
Delete with sendAgentMessage() |
MessageAdapter class |
— | agent/adapters/MessageAdapter.ts |
Delete — unused once sendAgentMessage() is gone |
CLI: Gemini migration
runGemini.ts is the only runner still on sendAgentMessage(). Wire in AcpSessionManager (already works for the generic ACP runner and OpenClaw) and route all message dispatch through it.
CLI: user text format
The CLI already emits only the modern role: 'session' envelope for user text. The ENABLE_SESSION_PROTOCOL_SEND flag that was planned as a rollout toggle was never implemented in code — removed from docs.
App legacy branches to remove (in typesRaw.ts rawAgentRecordSchema)
| Branch | content.type |
Action |
|---|---|---|
acp |
{ type: 'acp', provider, data } |
Remove after Gemini migration |
codex |
{ type: 'codex', data } |
Remove — Codex already on envelopes |
event |
{ type: 'event', data } |
Remove — replaced by service and turn-end |
output |
Raw Claude JSONL | Keep for historical message replay (oldest format, may exist in production databases) |
Also remove the hyphenated content normalization transforms (normalizeToToolUse() / normalizeToToolResult()) once no active clients send those formats.
happy-wire schema updates
- Rename
start/stop→subagent-start/subagent-stopwithagentIdfield - Add
usagetoturn-endschema - Add new event types:
session-control-changed,agent-configuration-changed,permission-request,permission-response,abort - Add
steerfield totextevent schema
Open Questions
steerfield naming:steer: truevssendType: "steer"vsqueuing: "immediate"— bikeshed later- Permission auto-approval: when a tool is auto-approved (from a previous
allow-always), emitpermission-request+permission-responseto the stream for audit, or skip for noise reduction? - Subagent
agentIdformat: cuid2 like today, or human-readable likesa_research? Examples above use readable ids for clarity but production would be cuid2 session-control-changedand metadata rollup: exact fields to mirror in session state TBD