Unless noted otherwise, endpoints require a JWT in the Authorization: Bearer <token> header or nerve_token cookie.
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 |
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.
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.
Verify current authentication.
Response: { "authenticated": true }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.
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.
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 |
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.
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
}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 }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 }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 }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 session details.
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 a session (cannot delete "main"). Disconnects any active SDK client before deletion.
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
}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 }Resume a stopped or idle session (must have a stored sdk_session_id).
Response: { "id": "a1b2c3d4", "status": "created", "sdk_session_id": "..." }Archive a session (soft delete, cannot archive "main"). Disconnects any active SDK client.
Response: { "archived": true }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": "..." }
]
}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 }
}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
}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.
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).
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", ... }] }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" }
}Distinct tags with their task counts, most-used first (filter-bar facets).
Response: { "tags": [{ "name": "backend", "count": 7 }] }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 task details including full markdown file content.
Response: { "id": "2026-03-01-fix-bug", "title": "Fix bug", "status": "pending", "content": "# Fix bug\n\n...", ... }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 }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": "..." }
] }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, ... } }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.
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
}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", ... }Run detail (same shape as the list items).
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", ... }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": ""
}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 full skill content, metadata, references, and usage stats.
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 }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 a skill (removes directory and DB record).
Enable or disable a skill.
Request: { "enabled": false }
Response: { "id": "code-review", "enabled": false }Get usage history and aggregate stats for a skill.
Aggregate usage stats across all skills.
Re-scan the workspace/skills/ directory and sync to DB. Discovers new skills, removes deleted ones, preserves enabled state.
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 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": "..." }]
}Paginated usage history for a server.
Re-read MCP server config from YAML files and refresh the in-memory cache. New sessions will use updated config.
Response: { "reloaded": 2, "servers": [...] }List markdown files in workspace.
Read a memory file.
Write a memory file.
Request: { "content": "# Updated content..." }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"] }
}Create a new category.
Request: { "name": "travel", "description": "Travel plans and logistics" }
Response: { "name": "travel", "created": true }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 }Update a memory item's content, type, or category assignments.
Request: { "content": "New text", "memory_type": "knowledge", "categories": ["work"] }
Response: { "id": "...", "updated": true }Delete a memory item.
Response: { "id": "...", "deleted": true }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, ... } }
}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
}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 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
}No auth required.
Response: { "status": "ok", "version": "0.1.0" }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.
// 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" }// 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" }