docs/session-protocol.md
This document defines the unified message protocol for Happy sessions. It replaces the existing mix of output, codex, and custom acp formats with a single, flat event stream. Old sessions continue using legacy formats; new sessions use this protocol exclusively.
For context on the existing wire protocol (WebSocket transport, encryption, sequencing), see protocol.md.
The real Agent Communication Protocol is an agent-to-agent interoperability standard over REST. Our protocol solves a different problem: rendering encrypted agent chat sessions on mobile/web clients.
| Concern | ACP | This protocol |
|---|---|---|
| Purpose | Agent-to-agent interop (REST) | Encrypted chat with agent sessions |
| Transport | REST + SSE | Encrypted payloads over WebSocket |
| Message model | Message { role, parts[] } with MIME types | Flat event stream, discriminated by t |
| Content typing | MIME types (text/plain, image/png) | Explicit event types (text, service, file, etc.) |
| Files | content_url or base64 with MIME type | Upload-first, referenced by ref |
| Images | Same as files (MIME-typed part) | file event with optional image metadata (width, height, thumbhash) |
| Tool calls | TrajectoryMetadata on parts | First-class tool-call-start / tool-call-end |
| Lifecycle | 7 run states, 11 SSE event types | turn-start / turn-end + agent start / stop |
| Event identity | UUID on runs, created_at on messages | id (cuid2) + time (ms) on every message |
Why not ACP directly?
file event can carry image metadata for instant placeholder layout.switch.What we take from ACP:
user / agent)content_url → ref)Every encrypted message payload:
{
"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; agent messages without turn are ignored |
subagent | cuid2? | Optional. Subagent identifier for messages produced by a subagent. Must be adapter-generated cuid2 |
ev | object | Event body, discriminated by ev.t |
When a tool call spawns a subagent (e.g. a Task tool), all messages produced by that subagent carry subagent set to an adapter-generated cuid2 id. Parent provider tool-call envelopes are optional; adapters may hide parent tool-call noise and emit only subagent lifecycle/content.
Subagents can nest — a subagent's tool call can spawn another subagent. Each level uses its own subagent id.
For provider adapters, orphan handling is a CLI responsibility: if a subagent message arrives before its parent subagent registration, the CLI should buffer and emit it only after the parent is known.
Provider-native ids (Claude/Codex tool ids, etc.) must not be used as subagent values.
textText content displayed to the user. Supports markdown.
{ "t": "text", "text": "Hello, how can I help?" }
| Field | Type | Description |
|---|---|---|
text | string | Message text (markdown) |
thinking | boolean? | Optional. true if this is internal reasoning, not shown to user by default |
serviceAgent-only service text shown to the user as-is. Supports markdown.
{ "t": "service", "text": "**Service:** reconnecting..." }
| Field | Type | Description |
|---|---|---|
text | string | Service message text (markdown) |
tool-call-startAgent begins a tool invocation.
{
"t": "tool-call-start",
"call": "tc_abc",
"name": "grep",
"title": "Searching for handleClick",
"description": "Searching for `handleClick` in **src/** directory",
"args": { "pattern": "handleClick", "path": "src/" }
}
| 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: `code`, bold, italic, [links]) |
description | string | Full description (inline markdown: `code`, bold, italic, [links]) |
args | object | Tool input arguments |
tool-call-endTool invocation completes. Matches a prior tool-call-start by call.
{ "t": "tool-call-end", "call": "tc_abc" }
| Field | Type | Description |
|---|---|---|
call | string | Matches tool-call-start.call |
fileFile attachment. The file must be uploaded to the server first.
{ "t": "file", "ref": "upload_def", "name": "report.pdf", "size": 524288 }
| Field | Type | Description |
|---|---|---|
ref | string | Server upload ID |
name | string | Display filename |
size | number | Required file size in bytes |
image | object? | Optional image metadata when the file is an image |
image.width | number | Image width in pixels |
image.height | number | Image height in pixels |
image.thumbhash | string | Base64-encoded ThumbHash for instant placeholder |
turn-startAgent begins processing. Always role: "agent". The envelope includes a turn id (cuid2) that identifies the turn. This turn value must be treated as the turn identifier; it is separate from message id.
{ "id": "a2", "turn": "t2", "ev": { "t": "turn-start" } }
turn-endAgent finishes processing. Always role: "agent". Carries the same turn as the messages it closes.
{ "t": "turn-end", "status": "completed" }
| Field | Type | Description |
|---|---|---|
status | "completed" | "failed" | "cancelled" | Final turn outcome |
startAgent lifecycle marker for subagent start. Always role: "agent". Use envelope subagent to identify which subagent started.
{ "t": "start", "title": "Research agent" }
| Field | Type | Description |
|---|---|---|
title | string? | Optional human-readable title for the subagent |
stopAgent lifecycle marker for subagent stop. Always role: "agent". Use envelope subagent to identify which subagent stopped.
{ "t": "stop" }
← { id: "a1", time: 1000, role: "user", ev: { t: "text", text: "Find TODOs" } }
← { id: "a2", time: 1001, role: "agent", turn: "t2", ev: { t: "turn-start" } }
← { id: "a2b", time: 1001, role: "agent", turn: "t2", ev: { t: "service", text: "**Service:** connected to remote runtime" } }
← { id: "a3", time: 1002, role: "agent", turn: "t2", ev: { t: "text", text: "Searching..." } }
← { id: "a4", time: 1003, role: "agent", turn: "t2", ev: { t: "tool-call-start", call: "tc1", name: "grep", title: "Searching for TODO", description: "Searching for `TODO` in project root", args: { pattern: "TODO" } } }
← { id: "a5", time: 1004, role: "agent", turn: "t2", ev: { t: "tool-call-end", call: "tc1" } }
← { id: "a6", time: 1005, role: "agent", turn: "t2", ev: { t: "text", text: "Found 3 TODOs." } }
← { id: "a7", time: 1006, role: "agent", turn: "t2", ev: { t: "turn-end", status: "completed" } }
The turn-start at a2 establishes turn: "t2". All subsequent agent messages carry that turn value, including the turn-end.
Agent spawning a subagent:
← { id: "c1", time: 3000, role: "agent", turn: "t2", ev: { t: "tool-call-start", call: "tc2", name: "task", title: "Exploring codebase", description: "Searching for **auth** implementations", args: { prompt: "Find auth code" } } }
← { id: "c2", time: 3001, role: "agent", turn: "t2", subagent: "v8x9j2q7k1n4m5p6r3s0t1u2", ev: { t: "start", title: "Auth explorer" } }
← { id: "c3", time: 3002, role: "agent", turn: "t2", subagent: "v8x9j2q7k1n4m5p6r3s0t1u2", ev: { t: "text", text: "Looking at src/auth/..." } }
← { id: "c4", time: 3003, role: "agent", turn: "t2", subagent: "v8x9j2q7k1n4m5p6r3s0t1u2", ev: { t: "tool-call-start", call: "tc3", name: "grep", title: "Searching for login", description: "Searching for `login` in **src/auth/**", args: { pattern: "login" } } }
← { id: "c5", time: 3004, role: "agent", turn: "t2", subagent: "v8x9j2q7k1n4m5p6r3s0t1u2", ev: { t: "tool-call-end", call: "tc3" } }
← { id: "c6", time: 3005, role: "agent", turn: "t2", subagent: "v8x9j2q7k1n4m5p6r3s0t1u2", ev: { t: "text", text: "Found auth handler." } }
← { id: "c7", time: 3006, role: "agent", turn: "t2", subagent: "v8x9j2q7k1n4m5p6r3s0t1u2", ev: { t: "stop" } }
← { id: "c8", time: 3007, role: "agent", turn: "t2", ev: { t: "tool-call-end", call: "tc2" } }
All messages carry turn: "t2" — they all belong to the same turn. Messages c2–c7 also carry the same cuid2 subagent value, linking them to the same subagent.
User sending an image file:
← { id: "b1", time: 2000, role: "user", ev: { t: "file", ref: "up_1", name: "screenshot.png", size: 153249, image: { width: 800, height: 600, thumbhash: "..." } } }
← { id: "b2", time: 2001, role: "user", ev: { t: "text", text: "What's in this screenshot?" } }
refid (cuid2) + time (ms) on the envelopeswitch(ev.t) in any clientkebab-case, no mixed conventionstitle and description support `code`, bold, italic, [links]Examples in this document may use short placeholder ids (for readability), but protocol values must still satisfy schema rules:
id: cuid2turn: cuid2 (when present)subagent: cuid2 (when present)