This document describes the API contract between a Shelley server and its
clients (the web UI, the iOS app, the CLI in client/, and tests). All
routes are mounted under /api/ unless noted; the version endpoint is at
/version.
GET /version
Returns build info plus capabilities (string list). Capabilities
advertise optional, additive features that clients can opt into when
present; a client that doesn't recognize a capability just doesn't use
it, and an older server that doesn't ship the field is equivalent to
advertising none.
Capabilities advertise optional behavior. Current capabilities include
thinking-levels, drafts, and queued-transcriptions; clients that do not
recognize one ignore it.
The server derives the conversation list from the database and publishes RFC-6902 JSON Patch diffs on a single unified SSE stream:
GET /api/stream2— unified SSE: per-conversation messages and conversation-list patches.GET /api/conversations/snapshot— seed the patch stream with the current list and its content hash.- Previews are embedded in each conversation list row (
preview,preview_updated_at). GET /api/conversation/<id>/streamsurvives for non-web clients (iOS, CLI, tests) but new clients should use/api/stream2.
The patch stream is driven exclusively by Pool.OnCommit: every
successful write transaction triggers a recompute, and a recomputeMu
serializes recomputes so concurrent commits can't publish events out of
order. Each event carries old_hash/new_hash, letting clients
reconcile their state on every patch.
GET /version—{tag, commit, commit_time, capabilities: [...]}.GET /version-check—{has_update, current_tag, latest_tag, ...}.GET /version-changelog— markdown changelog.
Unless noted, results exclude archived conversations.
GET /api/conversations?limit=&offset=— top-level (non-subagent) unarchived conversations as plain rows. Used by iOS and the CLI.GET /api/conversations/snapshot— the current unarchived list including subagents, plus per-row state (working, git info, subagent count, preview) and the patch-stream hash. Used by the web UI on load. Response:{ "conversations": [ConversationWithState, ...], "hash": "<sha256 hex>" }GET /api/conversations/archived— archived list.POST /api/conversations/new— create a conversation and post the first user message.POST /api/conversations/distill-new-generation— compact the current conversation into the next generation of the same conversation. The optionalmethodfield (defaultorcompact) is accepted for compatibility but is ignored: compaction is always used.
ConversationWithState row shape:
| field | meaning |
|---|---|
conversation_id, slug, created_at, updated_at, cwd, archived, parent_conversation_id, model, conversation_options, current_generation, agent_working, user_initiated |
DB columns |
working |
mirror of agent_working, kept for the patch-stream contract |
git_repo_root, git_worktree_root, git_commit, git_subject |
optional, from a cached HEAD lookup keyed by cwd |
subagent_count |
number of subagent conversations whose parent_conversation_id matches this row |
preview, preview_updated_at |
trailing text of the most recent agent message and its timestamp (RFC 3339); empty if no agent reply yet, or if this conversation is outside the 500-most-recent window the server tracks for previews |
GET /api/conversation/<id>— full message history (compressed).GET /api/conversation/<id>/stream— legacy SSE: messages, state, no list patches. Used by iOS, CLI, and Go tests; new clients should use/api/stream2. Query params:?last_sequence_id=<n>— resume fromsequence_id > n.?tail=<n>— first frame contains only the lastnmessages. A{"snapshot_complete": true}frame follows the initial replay and precedes live updates.
POST /api/conversation/<id>/chat— send a user message. A message of/transcription <absolute uploaded-media path>creates a durable specialized queued message and returns202 {"status":"queued"}. The path must name a regular file inside/tmp/shelley-uploads. A hidden low-reasoning child performs the transcription independently of the HTTP request. While it is working or failed, the item reserves its FIFO position; when ready, the ordinary queue drainer creates the immutable parent user turn. Optional context follows the path on subsequent lines of the command and is prepended to the transcript, preserving text and ready attachments that were present before recording. Video results include the original recording and generated contact sheet paths in that turn.POST /api/conversation/<id>/send-queued?queued_id=<id>— interrupt the running turn and immediately drain the FIFO head. The supplied id must still name that head item.POST /api/conversation/<id>/cancel-queued?queued_id=<id>— cancel and remove one queued item. Omittingqueued_idcancels the entire queue. Cancelling a working transcription also stops its hidden child.POST /api/conversation/<id>/retry-queued?queued_id=<id>— retry a failed queued transcription in place.POST /api/conversation/<id>/cancel— interrupt the running loop.POST /api/conversation/<id>/archive/unarchive.POST /api/conversation/<id>/hooks— register an end-of-turn webhook.GET /api/conversation-by-slug/<slug>— lookup by slug.
GET /api/stream2?conversation=<id>&conversation_list_hash=<h>&last_sequence_id=<n>
SSE stream. A single connection delivers per-conversation events
(messages, tool progress, stream deltas, conversation/state updates) for
all active conversations on the server, plus the conversation-list
patch stream. Every per-conversation event carries a top-level
conversation_id field for client-side routing.
All query params are optional:
conversation— if set, the first frames replay that conversation's message history before live updates begin. It governs backfill only: live events for every conversation flow regardless.conversation_list_hash— thehashfrom the most recent snapshot or patch event the client successfully applied. The server uses it to decide whether to replay history or send a fresh reset event.last_sequence_id,tail— refine theconversationbackfill, with the same semantics as on/api/conversation/<id>/stream. Asnapshot_completeframe separates the initial replay (and an empty replay on connections withoutconversation) from live updates.
Event payload (data: <json>):
interface StreamResponse {
// Routing key for per-conversation events. Always set on messages,
// conversation, conversation_state, context_window_size, tool_progress,
// and stream_delta. Empty for connection-scoped frames
// (conversation_list_patch, heartbeat, snapshot_complete) and for
// global events that already carry their own conversation reference
// (notification_event).
conversation_id?: string;
// Per-conversation event payload. With a single stream serving every
// active conversation, clients dispatch based on conversation_id.
messages?: APIMessage[];
conversation?: Conversation;
conversation_state?: { conversation_id, working, model };
context_window_size?: number;
tool_progress?: ToolProgress;
stream_delta?: StreamDelta;
notification_event?: NotificationEvent;
// Conversation-list patch stream:
conversation_list_patch?: {
old_hash: string | null, // null on a reset event
new_hash: string,
patch: RFC6902Op[], // ops with paths like "/0", "/0/working", etc.
at: string, // RFC 3339
reset?: true, // true for the seed event
};
heartbeat?: true; // sent every 30s if nothing else to say
snapshot_complete?: true; // once, after the initial replay
}The conversation_list_patch operates on a document that is exactly the
conversations array returned by /api/conversations/snapshot. Clients
should:
GET /api/conversations/snapshotonce to obtain(state, hash).- Open
/api/stream2?conversation_list_hash=<hash>. - For each
conversation_list_patchevent:- If
event.old_hash == nullorevent.reset, replace local state withevent.patch[0].value. - Otherwise, require
event.old_hash == currentHash; applyevent.patchvia RFC 6902; asserthashList(state) == new_hash. - On any mismatch, drop local state and resume with the snapshot.
- If
Reconnect semantics: the server keeps the last 100 patch events in
memory. If the client's conversation_list_hash matches one of those
boundaries, the server replays the missed patches; otherwise it sends a
fresh reset event.
GET /api/git/repos— repo discovery.GET /api/git/diffs?cwd=— staged/unstaged file lists.GET /api/git/diffs/<commit>?cwd=— committed file lists.GET /api/git/file-diff/<path>?cwd=&base=&head=— unified diff.GET /api/git/graph?cwd=— commit graph.GET /api/git/commit-detail?cwd=&sha=— single commit.GET /api/git/commit-messages?cwd=— recent commit messages.POST /api/git/amend-message— amend HEAD message.POST /api/git/create-worktree—git worktree add.
-
GET /api/list-directory?path=— directory listing. -
POST /api/create-directory—mkdir -p. -
POST /api/write-file— write a file. -
POST /api/upload— binary upload (multipart). -
POST /api/upload/raw?filename=— binary upload with the file content as the request body (no multipart framing). Newer clients prefer this to avoid building a multipart body on device. Older servers return 404/405; clients should fall back to the multipart endpoint. -
GET /api/upload/raw— empty200 OKif the server supports the raw upload endpoint; clients use this as a capability probe (older servers return 404/405). -
GET /api/read?path=— read a file (images served asimage/*). -
POST /api/validate-cwd— check whether a path is a valid working directory. -
GET /api/user-agents-md/POST— read/write the per-user AGENTS.md.
GET /api/models— available models.GET /api/tools— registered tool definitions.GET/POST/PUT/DELETE /api/custom-models[/<id>]— custom model CRUD.POST /api/custom-models-test— test a custom model config.GET/POST/PUT/DELETE /api/notification-channels[/<id>],GET /api/notification-channel-types— notification CRUD.
WS /api/exec-ws?cwd=— websocket for an interactive shell session.cmd=starts a new persistent session;term_id=reattaches to an existing one and never reruns the command.conversation_id=records which conversation owns a newly spawned terminal.GET /api/terminals— all persistent terminals, unfiltered.conversation_idis the owning conversation, ornullfor a terminal shown in every conversation.PUT /api/terminals/<id>/scope— move a terminal between conversations. Body{"conversation_id": "<id>"}confines it to that conversation;{"conversation_id": null}shows it in all of them.nullis the only accepted spelling of global: an absent field or an empty string is a 400. Returns the updated terminal.DELETE /api/terminals/<id>,POST /api/terminals/<id>/kill— terminate a terminal and drop its record.
GET /debug/conversations— HTML dump of the conversation list.GET /debug/conversation-stream— HTML viewer over the patch stream.GET /debug/conversation-stream/history— JSON dump of the last 100 patch events.