Skip to Content
Native IntegrationSession Controller

Session Controller

The session-level surface: list the user’s live Claude Code sessions, subscribe to one, receive the events Claude Code writes to JSONL as they happen, and (carefully) submit prompts.

What a “session” is

A session is a single Claude Code conversation living in a JSONL file at ~/.claude/projects/<workspace-slug>/<sid>.jsonl. Claude Code owns the file. The extension watches it. The phone sees what Claude Code writes — at the granularity of JSONL line writes, not token-level streaming.

Be precise in copy:

✅ “Assistant / session events appear on the phone as Claude Code writes them to JSONL.” ❌ “Tokens stream in real time.”

Capability

session_subscriptions

Clients with this capability must explicitly watch_session to receive session_events. Clients without it fall back to v1 broadcast-all behaviour. Native clients should always negotiate this capability — explicit subscription is doctrine-aligned.

Wire shapes

SessionInfo (sent in sessions snapshot)

{ "id": "a230f370-1634-4bdd-91d2-ffb1992ab916", "cwd": "/root/projects/agentic-ides", "startedAt": 1700000000000, "entrypoint": "claude-code-ide", "title": "User-provided or AI title (optional)" }

Client → Server

{ "type": "list_sessions", "requestId": "r-1" } { "type": "watch_session", "requestId": "r-2", "traceId": "t-...", "sessionId": "<sid>" } { "type": "unwatch_session", "requestId": "r-3", "sessionId": "<sid>" } { "type": "new_conversation", "requestId": "r-4" } { "type": "prompt", "requestId": "r-5", "traceId": "t-...", "message": "your prompt text", "sessionId": "<sid>" } { "type": "accept_diff", "requestId": "r-6", "traceId": "t-..." } { "type": "reject_diff", "requestId": "r-7", "traceId": "t-..." }

Server → Client

{ "type": "sessions", "sessions": [ /* SessionInfo[] */ ], "seqId": N } { "type": "session_started", "sessionId": "<sid>", "cwd": "/path", "entrypoint":"claude-code-ide", "seqId": N } { "type": "session_ended", "sessionId": "<sid>", "seqId": N } { "type": "watch_ack", "requestId": "r-2", "sessionId": "<sid>", "watching": true, "seqId": N } { "type": "session_event", "sessionId": "<sid>", "event": { "type": "assistant" | "user" | "tool_use" | "tool_result" | "thinking" | "ai-title" | "text" | "message", "data": { ... }, // raw JSONL line — back-compat fallback only "blocks": [ /* ContentBlock[] */ ] // typed projection — render THIS (see "Typed Tool Blocks" below) }, "seqId": N, "traceId": "t-...?" } { "type": "prompt_sent", "requestId": "r-5", "message": "<echo of submitted text>", "seqId": N }

Watch lifecycle (the explicit-subscription model)

1. client → server: list_sessions 2. ← sessions [ ... ] 3. user picks session sid 4. client → server: watch_session { sessionId: sid } 5. ← watch_ack { sessionId: sid, watching: true } 6. ← session_event (replay of last ~200 historical events from JSONL) 7. ← session_event (live, as Claude Code writes each new JSONL line) 8. user picks a different session 9. client → server: unwatch_session { sessionId: sid_old } 10. ← watch_ack { sessionId: sid_old, watching: false } 11. client → server: watch_session { sessionId: sid_new }

Important: watch_session triggers a replay of the last 200 events from the JSONL file (handled by MessageHandler.replayJsonl in the extension). Native clients should not re-request these themselves — the server delivers them on subscribe.

What native UI should render

For each session:

  • Title (preferring title from the latest ai-title event in JSONL → falling back to cwd basename → falling back to first 8 chars of id).
  • cwd (full path or basename, your call).
  • Last activity timestamp (track the highest seqId you’ve seen for this sid).
  • Active indicator if currently watched.

For the chat view inside a session:

  • Render each session_event according to its inner event.type:
    • user / text / message → user-aligned bubble.
    • assistant → assistant-aligned bubble.
    • thinking → collapsible “thinking” block.
    • tool_use → tool block with name + input (collapsed by default once completed).
    • tool_result → result attached to the matching tool_use.
    • ai-title → store it as the session title, don’t render as a chat message.
  • Unknown future event.type → render as (unknown event type: <type>) placeholder. Do not crash.

The block above describes the legacy raw-event.data path. The preferred render path is the typed projection in event.blocks — see the next section.

Typed Tool Blocks (session_event.event.blocks) — SHIPPED 2026-05-25

Additive to session_event. Render blocks; never parse event.data.

Claude Code writes its JSONL in its own message format (message.content[] arrays, tool_use / tool_result shapes, structuredPatch hunks). Historically each client re-parsed that format. That made a Claude Code format change an App-Store-gated emergency — the exact failure mode the doctrine forbids. Typed Tool Blocks removes it.

The extension now ships a typed projection alongside the preserved raw line:

{ "type": "session_event", "sessionId": "<sid>", "event": { "type": "assistant" | "user" | ..., "data": { /* raw JSONL line — preserved for back-compat only */ }, "blocks": [ /* ContentBlock[] — the typed projection */ ] }, "seqId": N }

ContentBlock (open kind and name unions)

type ContentBlock = | { kind: 'text'; text: string } | { kind: 'thinking'; text: string } | { kind: 'tool'; toolUseId: string; name: string; // OPEN union — e.g. 'Read' | 'Edit' | 'Bash' | (string & {}) input: Record<string, unknown> } // from the assistant line | { kind: 'tool_result'; toolUseId: string; isError: boolean; // from the user line text?: string; numLines?: number; edit?: EditResultInfo } interface EditResultInfo { added: number; // count of '+'-prefixed lines across all hunks removed: number; // count of '-'-prefixed lines across all hunks hunks: Array<{ oldStart: number; lines: string[] }>; // each line is '+'/'-'/' '-prefixed }

The doctrine (this is the whole point)

  • tool-projection.ts is the SOLE owner of the raw-JSONL → ContentBlock mapping. It extracts structuredPatch → added/removed/hunks, lifts numLines, and de-wraps <tool_use_error>. The native client does the equivalent of nothing — it consumes the already-typed blocks.
  • Native clients render blocks. They MUST NOT reach into event.data.message.content[], recompute diffs, or know what tool_use / tool_result / structuredPatch look like. If a future Claude Code release changes its JSONL shape, the extension updates its projection and ships through normal channels — the native binary is untouched.
  • Correlate tool_resulttool by toolUseId — a typed id-match, never a format parse. A tool block carries toolUseId; the matching tool_result block carries the same toolUseId. Attach the result to its tool row by equality.
  • kind and name are OPEN unions. Unknown kind → render a neutral generic block (show whatever text/label you have, never crash). Unknown tool name → render a generic tool row with the literal name.
  • Labels are CLIENT presentation, computed from the typed data — not format knowledge. "Read: foo.tsx", "Added 12 lines, removed 3 lines", etc. are derived from name + input + edit.added/removed. Mirror the PWA’s toolDisplay.ts (e.g. formatEditDelta, findTypedToolResult); do not hardcode Claude Code’s content format to produce them.
  • event.data is the rollout fallback ONLY. While the extension/PWA roll out, a session_event may arrive with blocks absent. In that case (and only that case) fall back to the legacy raw-event.type rendering above. Never extend that fallback with new format knowledge — it exists to bridge the deploy window, not to grow.

What native UI should render from blocks

BlockRender
textmessage text (user- or assistant-aligned per the enclosing event.type)
thinkingcollapsible “thinking” block
toola tool row: client-computed label from name + input (e.g. Read: foo.tsx), input collapsible
tool_resultattach to the matching tool row by toolUseId; show text, or a diff from edit.hunks, or Added N, removed M from edit.added/edit.removed; surface isError: true distinctly
unknown kindgeneric neutral block; do not crash

Liveness — turn_status + interrupt — SHIPPED 2026-05-25

The “is Claude working right now, and can I stop it?” surface. Capability: turn_status_observe.

JSONL writes are bursty — Claude Code can “crunch” for a long stretch writing no JSONL at all. Without a liveness signal, the phone can’t tell a finished turn from one still thinking. turn_status is that signal; the interrupt pair is the Stop button.

Capability

turn_status_observe

Negotiate it to receive turn_status broadcasts. (Read-only observation; the PWA negotiates it unconditionally.)

Server → client: turn_status

{ "type": "turn_status", "sessionId": "<sid>", "state": "working" | "idle" | (string & {}), // OPEN union "since": 1700000000000, // epoch ms the state began — drives the elapsed timer "seqId": N }

The extension (turn-tracker.ts) marks a session working on a user message — a phone-sent prompt or a user JSONL line — and idle on either:

  • an assistant terminal stop_reason (end_turn, etc.), or
  • a 120 s backstop — a safety net only, not a short quiet-timeout.

⚠️ The backstop is deliberately long. Claude writes no JSONL while “crunching”, so a short quiet-timeout would flip the indicator to idle mid-turn. Do not reimplement a shorter client-side timeout to “improve responsiveness” — you would kill the indicator while Claude is still working. since + the server’s idle event are the contract.

Native render — the working indicator

While the active session’s latest turn_status is working:

  • Show an animated glyph + a client-side rotating verb list (Working… / Generating… / Stewing… / …). This rotation is pure presentation — invent your own verbs; it carries zero Claude Code format knowledge.
  • Show elapsed time computed from since (now − since).
  • Honor reduced-motion OS settings: drop the animation, keep the static label + elapsed.
  • Unknown state (anything that isn’t working) → render nothing (the safe default is “not working”). Never crash.

Interrupt — the Stop button (data-driven, server-owned)

Client → server:

{ "type": "interrupt_request", "requestId": "r-9", "traceId": "t-...", "sessionId": "<sid>" }

Server → client:

{ "type": "interrupt_ack", "requestId": "r-9", "ok": true | false, "errorCode": "interrupt_unavailable" | "no_active_turn" | "bridge_error" | (string & {}), // OPEN union, present when ok:false "seqId": N }

Renderer contract:

  • Show the Stop button only while the active session is working.
  • On tap → send interrupt_request.
  • On interrupt_ack with ok: false and errorCode: 'interrupt_unavailable'disable the Stop button. That code means this extension build cannot interrupt — its webview control channel isn’t patched. Don’t keep re-offering an action the server just said it can’t do.
  • Other ok: false codes (no_active_turn, bridge_error, unknown) → surface the failure (toast / inline) and degrade gracefully; errorCode is an OPEN union, so render unknown codes without crashing.
  • The client never owns the interrupt mechanism. It sends a request and renders the ack. The extension owns the actual interrupt and degrades gracefully when it can’t perform one. Do not try to “interrupt locally” (e.g. by dropping the socket) — there is no client-side interrupt.

Optimistic prompt echo (PWA reference pattern — optional for natives)

The PWA renders the user’s own prompt bubble immediately on send (before any server round-trip), then dedupes it when the authoritative user session_event echoes back over the wire. This is a UX nicety, not a protocol requirement — a native client MAY render only on the echoed session_event if it prefers strict server-authority. If you do echo optimistically, dedupe against the incoming user event (e.g. by submitted text + a short time window) so the prompt doesn’t appear twice.

TraceId model (and its caveat)

The native client should generateTraceId() (UUID or short base36) for any user-initiated outbound message — prompt, watch_session, open_workspace, etc.

The server inherits the prompt’s traceId for post-prompt session_events via a 10-second TTL per-session map (MessageHandler.sessionTraceIds). For fast replies (< 10 s), the receive-side events carry the same traceId. For slow replies, traceId continuity is lost — that’s expected.

Treat traceId-linkage on session_event as best-effort evidence, not a contract. The canonical correlation in the receive direction is by sessionId.

Send path vs receive path

  • Send path is trace-by-traceId: web.ui.send_promptws.in (prompt)bridge.send_promptprompt_sent response. All carry the same traceId.
  • Receive path is trace-by-sid: watch.readsession_event (per JSONL write) → ws.out to phone. The traceId MAY inherit from the prompt’s traceId via the 10 s window, but should be considered optional.

Replay across reconnects

If the client disconnects and reconnects with lastSeqId, the server replays buffered messages with seqId > lastSeqId from the ring buffer (200 events deep). For session_events specifically:

  • If the buffer covers the gap → seamless.
  • If the buffer wrapped (rare; only for very long offline windows) → some events are silently lost. This is a known deferred reliability gap. A future gap_detected primitive may surface it; render it then.

For now, on reconnect, native clients should:

  1. Replay messages from lastSeqId.
  2. Re-issue watch_session for each previously-watched session to ensure subscription state matches.
  3. Optionally call list_sessions to reconcile the session list.

Doctrine notes

  • JSONL is the source of truth on the read path. The native client renders what arrives; it does not infer what Claude Code “must be doing.”
  • Don’t persist messages to disk. Per-session messages cap at 500 in memory (PWA precedent) and are rebuilt on watch_session replay.
  • Token-level streaming is not a thing here. Don’t promise it in UI copy. Don’t build animations that assume per-char arrival.
  • The native client never opens the JSONL files directly. Always go through the protocol.

Reference implementation