Skip to content

Latest commit

 

History

History
850 lines (652 loc) · 30.5 KB

File metadata and controls

850 lines (652 loc) · 30.5 KB

API Reference

REST API

Unless noted otherwise, endpoints require a JWT in the Authorization: Bearer <token> header or nerve_token cookie.

Auth

POST /api/auth/login

Log in and receive a session token. Authentication is not required.

Request:  { "password": "...", "username": "alice" }
Response: { "token": "eyJ..." }

username is optional when one account exists and required when there are two or more. Use GET /api/auth/status to determine which fields to show. On a passwordless installation, any password is accepted for the sole account.

Treat the returned token as opaque.

Outcome Response
Invalid credentials or missing required username 401 Invalid username or password
Valid credentials for a disabled account 401
Setup is not complete (login is setup) 409
Authentication is not ready 503

Authenticated requests

Send the token as Authorization: Bearer <jwt>, as the nerve_token cookie, or as ?token= (for <img src> and downloads, which cannot set headers).

Every request resolves its token to an account actor or the system principal. Unknown and disabled accounts fail with 401; unavailable startup identity state fails with 503. See Accounts and identity for token types and legacy-session compatibility.

When a session is more than halfway to expiry, the response includes a refreshed token in X-Nerve-Token. Replace the current token with it. The header also upgrades sessions created before per-account login and is exposed through CORS.

GET /api/auth/status

Return the required login fields. Authentication is not required.

Response: {
  "auth_required": true,
  "login": "password"
}
Field Meaning
login setup, none, password, or username_password
auth_required Compatibility field; equivalent to login != "none"

setup means setup is not complete: login is refused and only POST /api/setup/claim is permitted. none means the operator chose a passwordless installation.

The response does not expose usernames or the account count. Before authentication is ready, it returns the fail-closed username_password state.

GET /api/auth/check

Verify current authentication.

Response: { "authenticated": true }

GET /api/auth/me

Return the actor this credential acts as, and its local account.

Response: {
  "actor": { "id": "…", "kind": "human", "display_name": "Alice", "username": "alice" },
  "account": { "id": "…", "actor_id": "…", "username": "alice", "…": "…" }
}

actor has the shape of one GET /api/actors row. account has the shape of GET /api/accounts/me, or is null when the credential has no account, such as the system principal's. The web UI compares message and session authors with actor.id.

Accounts

Every signed-in account can manage accounts. System credentials cannot. Account responses never include password hashes or their storage location.

An account is:

{
  "id": "…",
  "actor_id": "…",
  "username": "alice",
  "display_name": "Alice",
  "enabled": true,
  "has_password": true,
  "created_at": "…"
}

id identifies the account. actor_id is the stable attribution ID and does not change when the username changes.

Endpoint Does
GET /api/accounts List accounts, oldest first; includes disabled accounts
GET /api/accounts/me Return the signed-in account
POST /api/accounts Create an account from {username, password, display_name?}
PATCH /api/accounts/{id} Update {username?, display_name?}
POST /api/accounts/{id}/disable Disable an account; idempotent
POST /api/accounts/{id}/enable Enable an account; idempotent
PUT /api/accounts/me/password Change the signed-in account's password using {current_password?, new_password}

Failures:

Response When
400 Invalid username or password longer than 72 UTF-8 bytes
403 System credential, or current password not supplied or incorrect
404 Account not found
409 Username or account-state conflict; the response explains the conflict

Disabling an account affects its next request and closes its open WebSockets.

Setup claim

POST /api/setup/claim

Complete setup of the one account on an install whose login status is setup. This is the only unauthenticated account write; every request must include the persisted setup token.

Request: {
  "username": "alice",
  "password": "…",
  "setup_token": "…",
  "display_name": "Alice"
}
Response: { "token": "eyJ…" }

To keep the installation passwordless, send "passwordless": true and no password. username is then optional:

Request: { "passwordless": true, "setup_token": "…" }

Send exactly one of password and "passwordless": true. display_name is optional. The response token is the client session minted after the claim; the actor and account are read through GET /api/auth/me, and names through the actor directory.

The setup token is read locally with nerve status, sent only in the JSON body, and invalidated after success. It never appears in a URL, response, server log, or browser storage. Use HTTPS or a protected tunnel when claiming remotely.

The claim and the setup completion record are one database transaction. Of concurrent claimants exactly one can win.

Response When
400 both or neither of password and "passwordless": true; a password claim without a username; the username is malformed/reserved; or the password exceeds bcrypt's 72-byte limit
403 the setup token is wrong or no stored token can match it; a concurrent loser may see this after the winner retires the token
409 setup is already complete, including a request that passed token validation before a concurrent winner, or a configured password
422 the setup token is missing/empty, the password is empty, or a field has the wrong type
503 no signing secret, or identity startup is incomplete

Actors

An actor is who something is attributed to: a person, or the agent's system principal. Sessions and messages store an actor id, never a name, so this is where a name is looked up at render time — a rename changes every label and moves no stored row.

{ "id": "…", "kind": "human", "display_name": "Alice", "username": "alice" }
Endpoint Does
GET /api/actors { "actors": [...] }, oldest first. One row per person plus the system principal, so a single call labels a whole list

kind is human or system.

username is the login name of the account behind a human actor, or null for the system principal and for an account that has not been given one. It is here so a label can fall back to it when display_name is empty, and it is the same name every signed-in account already sees on /accounts.

Otherwise this is an identity, not an account: nothing else from accounts appears here (no enabled, no has_password, no credential state), and an actor need not have an account at all — the system principal does not. A disabled person's actor is still readable, because their history stays in the UI after their access ends.

Sessions

GET /api/sessions?offset=0

Sidebar feed: one page of conversations plus every starred session.

sessions is a single page of the conversation feed (page size = sessions.sidebar_page_size, default 50; 0 = unlimited). The window covers only non-archived, non-system (cron/hook), non-starred rows, so cron traffic can never displace conversations. On the first page (offset=0) all starred sessions are prepended in full and are never truncated; pass the returned next_offset back as ?offset=N to load subsequent pages. archived_count/system_count are the collapsed-group badge counts, and has_more/next_offset drive the "…" load-more control.

Response: {
  "sessions": [{ "id": "main", "title": "Main", "source": "system", "updated_at": "..." }],
  "archived_count": 12,
  "system_count": 3,
  "has_more": true,
  "next_offset": 50
}

GET /api/sessions/archived?offset=0

One page of archived conversations — system/cron sessions are excluded. Fetched only when the sidebar's Archived group is expanded.

Response: { "sessions": [{ "id": "a1b2c3d4", "title": "Old chat", "status": "archived", "updated_at": "..." }], "has_more": false, "next_offset": 7 }

GET /api/sessions/system?offset=0

One page of live (non-archived) system/cron/hook sessions. Fetched only when the sidebar's System group is expanded.

Response: { "sessions": [{ "id": "cron-1", "title": "task-heartbeat", "source": "system", "updated_at": "..." }], "has_more": false, "next_offset": 3 }

POST /api/sessions/{id}/unarchive

Restore an archived session to idle so it resurfaces at the top of the conversation feed. Returns 404 if the session doesn't exist.

Response: { "unarchived": true }

POST /api/sessions

Create a new session.

Request:  { "title": "My Session" }
Response: { "id": "a1b2c3d4", "title": "My Session", "source": "web", "created_by_actor_id": "…" }

created_by_actor_id is an actor id for GET /api/actors, not an account id. It is null for legacy rows and sessions caused by an unidentified external person. Every session payload carries the field.

GET /api/sessions/{id}

Get session details.

GET /api/sessions/{id}/messages?limit=100

Get messages for a session.

Response: { "messages": [{ "id": 1, "role": "user", "content": "...", "channel": "web", "created_at": "...", "actor_id": "…" }] }

actor_id is who supplied the message: the signed-in person who typed it, or the system principal for a prompt Nerve composed itself. It is null on Slack, Telegram, and imported Codex human input until identity mappings exist; on assistant/tool output; and on legacy history. channel stays transport provenance and is never identity. See Accounts and identity for what is and is not attributed.

DELETE /api/sessions/{id}

Delete a session (cannot delete "main"). Disconnects any active SDK client before deletion.

GET /api/sessions/{id}/status

Session status with lifecycle info.

Response: {
  "session_id": "a1b2c3d4",
  "status": "active",
  "is_running": true,
  "sdk_session_id": "8fbba4a4-...",
  "connected_at": "2026-02-27T12:00:00+00:00",
  "parent_session_id": null,
  "message_count": 42,
  "total_cost_usd": 0.0
}

POST /api/sessions/fork

Fork a session, optionally from a specific message point (at_message_id is a numeric message row id as a string). The fork branches the source's native conversation on its first turn (Claude --fork-session + --resume-session-at; Codex thread/fork + lastTurnId) and is created pre-populated with the source's messages up to the fork point, so the new chat displays exactly the history the agent remembers.

Errors: 404 unknown source; 409 not forkable — the source has no native conversation yet (no completed turn), or the anchor message has no native turn mapping (sessions predating per-turn recording can only be forked whole).

Request:  { "source_session_id": "main", "at_message_id": "42", "title": "My Fork" }
Response: { "id": "fork-a1b2c3d4", "title": "My Fork", "source": "web", "status": "created", "parent_session_id": "main", "message_count": 12 }

POST /api/sessions/{id}/resume

Resume a stopped or idle session (must have a stored sdk_session_id).

Response: { "id": "a1b2c3d4", "status": "created", "sdk_session_id": "..." }

POST /api/sessions/{id}/archive

Archive a session (soft delete, cannot archive "main"). Disconnects any active SDK client.

Response: { "archived": true }

GET /api/sessions/{id}/events?limit=50

Get session lifecycle event log (newest first).

Response: {
  "events": [
    { "id": 3, "session_id": "abc", "event_type": "idle", "details": { "resumable": true }, "created_at": "..." },
    { "id": 2, "session_id": "abc", "event_type": "started", "details": { "sdk_session_id": "..." }, "created_at": "..." },
    { "id": 1, "session_id": "abc", "event_type": "created", "details": { "source": "web" }, "created_at": "..." }
  ]
}

Modified Files

GET /api/sessions/{id}/modified-files

List files modified during a session with diff stats. Reads from session_file_snapshots table and compares against current file content on disk.

Response: {
  "files": [
    { "path": "/home/user/project/foo.py", "short_path": "project/foo.py", "status": "modified", "stats": { "additions": 15, "deletions": 3 }, "created_at": "2026-03-04T07:00:00Z" }
  ],
  "summary": { "total_files": 1, "total_additions": 15, "total_deletions": 3 }
}

GET /api/sessions/{id}/file-diff?path=...&context=4

Compute a unified diff for a single file against its session baseline snapshot. Returns structured hunks with line numbers for GitHub PR-style rendering. For markdown files (.md, .markdown) the response also carries markdown_content — the post-change file content (original content for deleted files) used by the UI's rendered-preview toggle — plus markdown_truncated when it was cut at the diff line limit. Both are null/false for other file types.

Response: {
  "path": "/home/user/project/foo.py",
  "short_path": "project/foo.py",
  "status": "modified",
  "binary": false,
  "stats": { "additions": 15, "deletions": 3 },
  "hunks": [
    {
      "old_start": 10, "old_count": 5, "new_start": 10, "new_count": 7, "header": "class Foo:",
      "lines": [
        { "type": "context", "content": "    def bar(self):", "old_line": 10, "new_line": 10 },
        { "type": "deletion", "content": "        return None", "old_line": 11 },
        { "type": "addition", "content": "        return 42", "new_line": 11 }
      ]
    }
  ],
  "truncated": false,
  "markdown_content": null,
  "markdown_truncated": false
}

Chat

POST /api/chat

Send a message and get the complete response (non-streaming).

Request:  { "message": "Hello", "session_id": "main" }
Response: { "response": "Hi there!", "session_id": "main" }

For streaming, use the WebSocket endpoint.

Tasks

GET /api/tasks?status=pending&tag=backend&sort=position

List tasks. All filters optional. Statuses are configurable — see GET /api/task-statuses; empty means all non-done. sort accepts deadline (default), updated_at, created_at, or position (board order).

GET /api/tasks/search?q=keyword&status=

Full-text search on task titles and content (FTS5). Optional status filter.

Response: { "tasks": [{ "id": "2026-03-01-fix-bug", "title": "Fix bug", "status": "pending", ... }] }

GET /api/tasks/board?limit=100&tag=

Every lane in one round trip — the board's only read. Returns the configured statuses plus one page of each, ordered by position. total is the lane's true count so a column can offer "+N more"; the done lane is capped tighter than the rest since it grows without bound.

Response: {
  "statuses": [{ "name": "pending", "label": "Pending", "color": "#...", ... }],
  "lanes": [{ "status": "pending", "total": 12, "tasks": [ ... ] }],
  "status_since": { "2026-03-01-fix-bug": "2026-03-02T10:00:00Z" }
}

GET /api/tasks/tags?include_done=false

Distinct tags with their task counts, most-used first (filter-bar facets).

Response: { "tags": [{ "name": "backend", "count": 7 }] }

POST /api/tasks

Create a task. Returns the created row; 409 if the duplicate guard refuses (retry with confirm_duplicate: true to override), 422 if status names a status that does not exist — a retry cannot fix that one, so it is kept distinct from the collision.

Request:  { "title": "Fix bug", "content": "Details...", "deadline": "2026-03-01", "tags": "backend,urgent" }
Response: { "task": { "id": "2026-03-01-fix-bug", ... }, "message": "Task created: ..." }
409:      { "detail": { "reason": "duplicate", "duplicates": [ ... ], "message": "..." } }
422:      { "detail": { "reason": "invalid_status", "duplicates": [], "message": "..." } }

GET /api/tasks/{id}

Get task details including full markdown file content.

Response: { "id": "2026-03-01-fix-bug", "title": "Fix bug", "status": "pending", "content": "# Fix bug\n\n...", ... }

PATCH /api/tasks/{id}

Update a task. All fields are optional. content replaces the full markdown file; title and deadline are re-synced to SQLite. Returns the full updated row.

deadline and tags read by presence: omit the key to leave the field alone, or send "" to clear it. An invalid status is a 400 (previously reported as a success that changed nothing).

Request:  { "status": "done", "note": "Fixed in PR #123" }
Request:  { "content": "# Updated Title\n\n**Deadline:** 2026-03-15\n\nNew details..." }
Request:  { "deadline": "" }
Response: { "task": { ... }, "task_id": "2026-03-01-fix-bug", "updated": true }

GET /api/tasks/{id}/events

A task's status history, oldest first.

Response: { "events": [
  { "id": 1, "task_id": "...", "from_status": null, "to_status": "pending", "actor": "system", "created_at": "..." },
  { "id": 2, "task_id": "...", "from_status": "pending", "to_status": "in_progress", "actor": "impl-abc123", "created_at": "..." }
] }

POST /api/tasks/{id}/move

Place a task in a lane — the board's drag-and-drop write. Send intent ("between these two cards"), not a computed rank: the server resolves the neighbours itself, so a stale client board can't corrupt the ordering. before_id is the card that ends up directly above, after_id the one directly below; omit both to append. Omit status to reorder in place.

Moving into or out of done moves the markdown file between active/ and done/ as a side effect.

Request:  { "status": "in_progress", "before_id": "2026-03-01-a", "after_id": "2026-03-01-b" }
Response: { "task": { "id": "...", "status": "in_progress", "position": 3072.0, ... } }

Workflow Runs

Budget-capped multi-agent jobs. See workflow-runs.md for semantics (engines, budget metering, lifecycle, journals). On the wire, spec.prompt is trimmed to 500 chars.

GET /api/workflow-runs?status=&limit=50&offset=0

List runs, newest first. status: active (pending+running), an exact status (pending, running, done, failed, killed, budget_exhausted), or empty for all.

Response: {
  "runs": [{
    "id": "wfr-a1b2c3d4", "engine": "claude-workflow", "title": "Sample batch audit",
    "spec": { "prompt": "Audit samples/batch-07/ for ..." },
    "status": "running", "budget_usd": 12.0, "spent_usd": 3.42, "warned_at": null,
    "session_id": "workflow:wfr-a1b2c3d4", "journal_dir": "/home/alice/.nerve/workflow-runs/wfr-a1b2c3d4",
    "created_by": "session:main", "error": null, "result": null,
    "created_at": "...", "started_at": "...", "finished_at": null, "updated_at": "..."
  }],
  "total": 1
}

POST /api/workflow-runs

Start a run. engine is claude-workflow or codex-ultracode; budget_usd is required unless workflows.allow_unbudgeted is set. Optional: title, model, effort, cwd (must be an existing directory). Returns the created run immediately (pending, or running once dispatched); execution happens in the background.

Request:  { "engine": "claude-workflow", "prompt": "Audit samples/batch-07/ for ...", "budget_usd": 12, "title": "Sample batch audit" }
Response: { "id": "wfr-a1b2c3d4", "status": "pending", ... }

GET /api/workflow-runs/{id}

Run detail (same shape as the list items).

POST /api/workflow-runs/{id}/kill

Terminate a run. Scoped strictly to the run's own session/subprocess; idempotent on already-terminal runs.

Request:  { "reason": "superseded" }
Response: { "id": "wfr-a1b2c3d4", "status": "killed", ... }

GET /api/workflow-runs/{id}/journal

Journal contents from <runs_dir>/<run-id>/: the run.json snapshot, the parsed events.ndjson lifecycle events (created, started, budget_warning, terminal status, enforced_stop), and result.md when the run finished.

Response: {
  "run_json": { "id": "wfr-a1b2c3d4", ... },
  "events": [
    { "ts": "...", "run_id": "wfr-a1b2c3d4", "event": "created", "engine": "claude-workflow", "budget_usd": 12.0, "created_by": "session:main" },
    { "ts": "...", "run_id": "wfr-a1b2c3d4", "event": "started", "session_id": "workflow:wfr-a1b2c3d4", "backend": "claude", "model": "..." }
  ],
  "has_result": false,
  "result": ""
}

Skills

GET /api/skills

List all skills with aggregated usage statistics.

Response: {
  "skills": [{
    "id": "my-skill", "name": "my-skill", "description": "Query database...",
    "version": "1.0.0", "enabled": true, "total_invocations": 5, "success_count": 5,
    "avg_duration_ms": 12, "last_used": "2026-03-06T21:00:00"
  }]
}

GET /api/skills/{id}

Get full skill content, metadata, references, and usage stats.

POST /api/skills

Create a new skill.

Request:  { "name": "code-review", "description": "This skill should be used when...", "content": "## Steps\n..." }
Response: { "id": "code-review", "name": "code-review", "created": true }

PUT /api/skills/{id}

Update a skill's SKILL.md content (full raw file including frontmatter).

Request:  { "content": "---\nname: code-review\ndescription: ...\n---\n\n# Instructions\n..." }
Response: { "id": "code-review", "name": "code-review", "updated": true }

DELETE /api/skills/{id}

Delete a skill (removes directory and DB record).

PATCH /api/skills/{id}/toggle

Enable or disable a skill.

Request:  { "enabled": false }
Response: { "id": "code-review", "enabled": false }

GET /api/skills/{id}/usage?limit=50

Get usage history and aggregate stats for a skill.

GET /api/skills/stats

Aggregate usage stats across all skills.

POST /api/skills/sync

Re-scan the workspace/skills/ directory and sync to DB. Discovers new skills, removes deleted ones, preserves enabled state.

MCP Servers

GET /api/mcp-servers

List all MCP servers with aggregated usage statistics.

Response: {
  "servers": [{
    "name": "nerve", "type": "sdk", "enabled": true, "tool_count": 34,
    "total_invocations": 127, "success_count": 125, "avg_duration_ms": null,
    "last_used": "2026-03-14T19:00:00", "first_seen_at": "...", "last_seen_at": "..."
  }]
}

GET /api/mcp-servers/{name}

Get server detail including per-tool breakdown and recent usage.

Response: {
  "name": "nerve", "type": "sdk", ...,
  "tools": [{ "tool_name": "task_list", "invocations": 42, "success_count": 42, "avg_duration_ms": null, "last_used": "..." }],
  "recent_usage": [{ "id": 1, "server_name": "nerve", "tool_name": "task_list", "session_id": "abc", "success": true, "created_at": "..." }]
}

GET /api/mcp-servers/{name}/usage?limit=50

Paginated usage history for a server.

POST /api/mcp-servers/reload

Re-read MCP server config from YAML files and refresh the in-memory cache. New sessions will use updated config.

Response: { "reloaded": 2, "servers": [...] }

Memory Files

GET /api/memory/files

List markdown files in workspace.

GET /api/memory/file/{path}

Read a memory file.

PUT /api/memory/file/{path}

Write a memory file.

Request: { "content": "# Updated content..." }

memU Semantic Memory

GET /api/memory/memu

Get memU categories, items, and indexed resources.

Response: {
  "available": true,
  "categories": [{ "id": "...", "name": "preferences", "description": "...", "summary": "..." }],
  "items": [{ "id": "...", "memory_type": "profile", "summary": "User works at Acme Corp", "resource_id": "...", "created_at": "...", "happened_at": "..." }],
  "resources": [{ "id": "...", "url": "/path/to/file.md", "modality": "document", "caption": "...", "created_at": "..." }],
  "category_items": { "category_id": ["item_id_1", "item_id_2"] }
}

POST /api/memory/memu/categories

Create a new category.

Request:  { "name": "travel", "description": "Travel plans and logistics" }
Response: { "name": "travel", "created": true }

PATCH /api/memory/memu/categories/{id}

Update a category's summary or description. Re-embeds the category after update.

Request:  { "summary": "Updated summary text", "description": "New description" }
Response: { "id": "...", "updated": true }

PATCH /api/memory/memu/items/{id}

Update a memory item's content, type, or category assignments.

Request:  { "content": "New text", "memory_type": "knowledge", "categories": ["work"] }
Response: { "id": "...", "updated": true }

DELETE /api/memory/memu/items/{id}

Delete a memory item.

Response: { "id": "...", "deleted": true }

GET /api/memory/memu/health

memU service health metrics and operation stats.

Response: {
  "initialized_at": "...", "service_available": true,
  "operations": { "recall": { "call_count": 5, "avg_duration_s": 0.8, "error_count": 0 }, ... },
  "in_flight": [],
  "database": { "total_items": 2924, "total_categories": 24, "db_size_mb": 132.97, "type_distribution": { "profile": 671, ... } }
}

GET /api/memory/memu/audit?action=&target_type=&limit=100&offset=0

Paginated audit log of memU mutations.

Response: {
  "logs": [{ "id": 1, "timestamp": "...", "action": "item_deleted", "target_type": "item", "target_id": "abc123", "source": "agent_tool", "details": {} }],
  "offset": 0, "limit": 100
}

Diagnostics

GET /api/diagnostics

System health and status, including task/FTS index health.

Response: {
  "system": { "hostname": "...", "memory_mb": 65.2, "disk_free_gb": 180.5 },
  "tasks": { "total": 92, "active": 16, "done": 76, "fts_indexed": 92, "fts_ok": true },
  "sync": { "github": { "cursor": "...", "last_run": "...", "records_fetched": 3, "records_processed": 3, "error": null } },
  "recent_cron_logs": [...]
}

GET /api/cron/logs?job_id=&limit=50&offset=0

Get cron job execution logs, newest first. limit is clamped to 1–200; combine with offset for pagination. Each log row carries the session_id of the chat session the run executed in (null for source runners and missed runs).

Response: {
  "logs": [ { "id": 12, "job_id": "...", "status": "success", "session_id": "cron:...", ... } ],
  "total": 234,
  "limit": 50,
  "offset": 0
}

Health

GET /health

No auth required.

Response: { "status": "ok", "version": "0.1.0" }

WebSocket Protocol

Connect to ws[s]://host:port/ws?token=<jwt> (the nerve_token cookie works too). The token is resolved to an actor at admission. A credential that names nobody is refused with close code 4001. The actor stays fixed until the socket reconnects. Disabling the account closes the socket with code 1008.

Unlike REST, a WebSocket never hands back a refreshed token — it has no response headers. The browser's ordinary REST traffic keeps the stored token fresh.

Client → Server

// Send a chat message
{ type: "message", content: "Hello", session_id: "main" }

// Stop the running agent
{ type: "stop", session_id: "main" }

// Switch active session
{ type: "switch_session", session_id: "abc123" }

// Fork a session
{ type: "fork", session_id: "main", at_message_id: "msg-42", title: "My Fork" }

// Resume a stopped/idle session
{ type: "resume", session_id: "abc123" }

// Keep-alive
{ type: "ping" }

Server → Client

// Streaming token (parent_tool_use_id set when from a sub-agent)
{ type: "token", session_id: "main", content: "Hello", parent_tool_use_id?: "toolu_parent" }

// Extended thinking
{ type: "thinking", session_id: "main", content: "Let me check...", parent_tool_use_id?: "toolu_parent" }

// Tool call started
{ type: "tool_use", session_id: "main", tool: "Read", input: { file_path: "..." }, tool_use_id: "toolu_...", parent_tool_use_id?: "toolu_parent" }

// Tool call result
{ type: "tool_result", session_id: "main", tool_use_id: "toolu_...", result: "...", is_error: false, parent_tool_use_id?: "toolu_parent" }

// Sub-agent started (Task tool invoked)
{ type: "subagent_start", session_id: "main", tool_use_id: "toolu_...", subagent_type: "Explore", description: "find auth", model?: "haiku" }

// Sub-agent completed
{ type: "subagent_complete", session_id: "main", tool_use_id: "toolu_...", duration_ms: 12345, is_error: false }

// Agent turn complete (includes context usage and boundary)
{ type: "done", session_id: "main", usage: { input_tokens: 1234, output_tokens: 567, cache_read_input_tokens: 890, cache_creation_input_tokens: 0 }, max_context_tokens: 1048576, context_boundary: "2026-02-25T10:00:00+00:00" }

// Agent stopped by user
{ type: "stopped", session_id: "main" }

// Error occurred
{ type: "error", session_id: "main", error: "..." }

// Another client of this session sent a message (the sender sees its own
// optimistically; actor_id is who sent it, matching the stored row)
{ type: "user_message", session_id: "main", content: "Hello", blocks: null, actor_id: "…" }

// Session switch confirmed (includes running state, lifecycle status, buffered events for reconnect)
{ type: "session_status", session_id: "abc123", is_running: true, status: "active", buffered_events: [...] }
{ type: "session_switched", session_id: "abc123" }

// Session title updated (AI-generated)
{ type: "session_updated", session_id: "abc123", title: "Italy Vacation Planning" }

// Session forked
{ type: "session_forked", source_id: "main", fork_id: "fork-a1b2c3d4", title: "My Fork" }

// Session resumed
{ type: "session_resumed", session_id: "abc123" }

// Session archived
{ type: "session_archived", session_id: "abc123" }

// Plan file updated (Write/Edit to .claude/plans/)
{ type: "plan_update", session_id: "main", content: "# Plan\n..." }

// Workflow run created / status or spend changed (broadcast to all clients;
// session_id is the run's own session, null before dispatch)
{ type: "workflow_run_update", session_id: "workflow:wfr-a1b2c3d4", run: { id: "wfr-a1b2c3d4", status: "running", spent_usd: 3.42, budget_usd: 12.0, ... } }

// File modified by agent (Edit/Write/NotebookEdit succeeded)
{ type: "file_changed", session_id: "main", path: "/home/user/project/foo.py", operation: "edit", tool_use_id: "toolu_..." }

// Interactive tool waiting for user input (AskUserQuestion, ExitPlanMode, EnterPlanMode)
{ type: "interaction", session_id: "main", interaction_id: "uuid", interaction_type: "question" | "plan_exit" | "plan_enter", tool_name: "AskUserQuestion", tool_input: { ... } }

// Keep-alive response
{ type: "pong" }