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. The request acceptsmessage, optionalmodelandcwd, and optionalconversation_options:{ "message": "run the tests", "model": "glm-5.3-fireworks", "conversation_options": { "thinking_level": "high", "tool_overrides": {"bash": "off"}, "disable_all_tools": false, "disable_notifications": true } }thinking_levelis one ofoff,minimal,low,medium,high,xhigh, ormax; the server also validates it against the selected model's advertised levels. Tool override values areonoroff. Withdisable_all_tools, an explicitonoverride re-enables that tool. The conversation starts from the settings of the profile named byconversation_options.profile, or the default profile (see Profiles below). A non-emptymodeloverrides the profile's, as do thethinking_level,tool_overrides,compact_nudge_tokens, andsystem_promptoptions where present, even as"",{}, or0.POST /api/conversations/distill-new-generation— ask the agent ofsource_conversation_idto compact its context in place, enabling thecompact_in_placetool if needed. Optionalinstructionsare appended to the request. The request is queued behind a running turn.
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 API message history (compressed) plus conversation metadata. Message rows includemessage_id,conversation_id,sequence_id,type,llm_data,user_data,usage_data,other_usage_data,created_at,display_data,generation,end_of_turn,llm_api_url,model_name,forked_from_message_id, anduser_email. The JSON-valued database columns (llm_data,user_data,usage_data,other_usage_data, anddisplay_data) are encoded as JSON strings on the wire.llm_data.Contentcarries text, thinking, tool-use, and tool-result blocks. Provider continuation secrets and inline image data are removed before serving.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.POST /api/conversation/<id>/tags— replace the conversation's tag list.GET /api/conversation/<id>/settings— the settings later turns use, and the profile they came from (a label; either may have changed since):{"profile": "Research", "model": "claude-opus-5.5", "thinking_level": "high", "tool_overrides": {"browser": "off"}, "compact_nudge_tokens": 200000, "system_prompt": ""}POST /api/conversation/<id>/settings— change them. The body has the same fields; those left out keep their values, andprofile, if present, first replaces all of them with that profile's."model": ""means the server's default. Whenthinking_levelis left out, a model that lacks the level rounds it to one it has; an explicit level the model lacks is a400, as are unknown fields. Responds with the settings now in effect. Unless only the profile label changes, a running turn is stopped first, and queued messages then go out on the new settings (those the turn had already taken in end with it, as with Stop);409if another turn starts before the change lands. Changes record amodelchangemessage saying what changed (profile_to,from/to,reasoning_to,tools_on/tools_off,compact_nudge_tokens,system_prompt_changed,text, and the settings before,previous), just after a newsystemmessage if the template or the tools changed. A fork from before the change gets the settings before it.409if the conversation is archived or a draft (a draft's settings travel with its first message), for a customsystem_promptin a subagent, or for anything but the model and reasoning in a btw reader.POST /api/conversation/<id>/live-session— start a GPT-Live voice call:{"sdp", "model"?, "cwd"?, "conversation_options"?}(the settings only for a draft) →201 {"session":{"id"},"transport":{"type","sdp"}}. Shelley writes the voice prompt and holds the key. The session id also names Shelley's side of the call (capabilitylive-call-events), which decides what the user's speech becomes, delivers it asSpoken request:chat messages (promoting a draft with the call's settings), and tells Live about typed messages and final replies. The client relays and follows:POST /api/conversation/<id>/live-session/<session>/events—{"events":[<Live data-channel event>...]}, verbatim and in order, one request at a time. Shelley readssession.input_transcript.delta,session.output_transcript.delta,session.delegation.createdandsession.closed.204;400Invalid live events;404Live call not found.GET /api/conversation/<id>/live-session/<session>/stream— SSE of instructions,idnumbered; resume with?after=<id>orLast-Event-ID.{"type":"send","event"}: sendeventto Live;{"type":"error","message"}: show the error,nullclears it;{"type":"activity","handing_off"};{"type":"stop","message"}: end the call showingmessage. Unknown types are ignored. The call ends a minute after its last stream detaches.DELETE /api/conversation/<id>/live-session/<session>— hang up: pending speech is dropped.204.
POST /api/conversation/<id>/live-message— for clients withoutlive-call-events: deliver one delegation the client decided on,{"transcript", "voice_context"?, "model"?, "cwd"?, "conversation_options"?}→ chat's receipt.GET /api/conversation/<id>/subagents— direct child conversations. Clients can recurse through this endpoint when they need the complete descendant tree (for example, aggregate usage reporting).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/find-files?dir=&q=&content=skip&include_dirs=true— fuzzy name search.diris absolute;qis optional.include_dirsis an optional boolean (default false); enabling it includes nested and empty folders in name results. Invalid or repeatedinclude_dirsvalues return 400. Results containsearch_dirandmatches; paths are relative tosearch_dir. Folder matches haveis_dir: trueand a trailing/inpath; file matches omitis_dir.content=onlyalways returns file-content hits, never folders. An explicit existing directory path without a trailing slash offers that folder itself wheninclude_dirsis true; a trailing slash browses inside it. File-only callers retain their existing behavior. -
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. OpenAI-compatible models acceptreasoning_replayasauto,reasoning_content, ornone; responses includeresolved_reasoning_replaywhen catalog resolution is known.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.
A profile is a named set of conversation settings: model ("" means the
server's default model), thinking_level ("" means the model's default),
tool_overrides, compact_nudge_tokens (0 means the default), and
system_prompt, a Go text/template
("" means Shelley's built-in prompt, which changes with Shelley). Exactly
one profile is the default; new conversations start from it unless they name
another. A conversation records the profile it came from in
conversation_options.profile. Its settings are copy-on-write: until a
change of its own sets conversation_options.settings_edited, the
conversation takes the profile's edits at its next turn, with a marker saying
what changed. Switching to a profile clears the flag. A deleted profile
leaves the settings as they are.
Shelley pre-installs Default (the coding profile, initially selected) and
Assistant (an opt-in, text-message-first personal assistant for life and
work). Assistant enables message_user and compact_in_place with a
130k-token compaction nudge, disables llm_one_shot and output_iframe,
replies in plain text through message_user, compacts in place after 8 idle
minutes and keeps the prompt cache warm until then. Both profiles use the
server's default model. Profiles can be edited; a non-default
profile can be deleted. Built-in profiles have builtin: true and start with
user_edited: false; Shelley refreshes them on startup until an actual user
edit sets user_edited: true. Unchanged saves and default selection do not
count as edits. Restore defaults resets their settings and clears that flag.
Deleted profiles and pre-existing custom profiles with the same name stay
untouched. Restoring follows the same copy-on-write rule as edits.
New messaging webhook and web chats start from Assistant when it exists;
otherwise they use the server's default model and built-in prompt. They
always enable message_user. A first message's explicit system_prompt
overrides the profile's prompt and makes the chat's settings its own. Later
events' prompts are ignored.
A profile also has a voice_prompt (trimmed, up to 4000 characters; ""
means none): who voice calls speak as. It is not a setting: conversations
don't copy it, and a call reads the current voice prompt of the profile its
conversation names.
GET /api/profiles— all profiles, default first:[{"name", "default", "builtin", "user_edited", "model", "thinking_level", "tool_overrides", "compact_nudge_tokens", "system_prompt", "voice_prompt"}].POST /api/profiles— create one:name(trimmed; up to 64 bytes, no/or control characters, not.or..), the settings fields and optionallyvoice_prompt(unknown fields are a400);409if the name is taken.PUT /api/profiles/<name>— replace a profile's settings; the body has only the settings fields and optionallyvoice_prompt, which keeps its value when absent. Profiles can't be renamed.POST /api/profiles/voice-prompt/generate— suggest a voice prompt:{"model", "system_prompt"}→{"voice_prompt"}.""means the server's default model or the built-in system prompt; an unknown model is a400. A model of the same provider and account writes it from the system prompt alone; nothing is saved.502if it fails.POST /api/profiles/<name>/default— make it the default profile.POST /api/profiles/<name>/restore— restore a built-in's current shipped settings and voice prompt, re-enabling automatic updates. Returns the profile;400for a custom profile,404if missing. Default selection and existing conversations are unchanged.DELETE /api/profiles/<name>—409for the default profile.GET /api/system-prompt— the built-in template and the variables a template can use:{"template", "variables": [{"name", "description"}]}. Templates are rendered withmissingkey=errorand checked on save, with every optional variable both set and unset (outside a git repository,.GitInfois nil); a template that fails is a400saying where and why, e.g.Invalid system_prompt: line 2: unknown variable .Nope.POST /api/system-prompt/check— check a template as a save would:{"template"}→{"error": null}or{"error": {"line", "column", "message"}}, the line and the column (in characters) counted from 1 and left out when unknown.
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/— index of the debug pages.GET /debug/conversation-stream/history— JSON dump of the last 100 patch events.