REST endpoints
Endpoint tables for native clients, grouped by feature. Request/response shapes reference the Zod schemas in shared/src/schemas.ts and shared/src/apiTypes.ts (package @hapi/protocol) — those schemas, not the prose here, are the field-level source of truth. Route behavior is grounded in hub/src/web/routes/*.ts; web/src/api/client.ts is the reference consumer.
These tables describe the hub interface. The native app guide records current iOS/Android UI support; an API wrapper or wire field alone does not mean the app offers that feature.
Conventions
- All paths below are relative to the hub base URL.
/apiroutes requireAuthorization: Bearer <JWT>except the/api/authexchange and Telegram/api/bind(Auth). - Path params (
:id,:messageId, …) must be URL-encoded (the web client usesencodeURIComponentthroughout). - Request bodies are JSON (
content-type: application/json) with one exception:POST /api/voice/transcriptionismultipart/form-data. Responses are JSON unless noted (generated images and scratchlist attachments return raw bytes). - Bodies are validated with Zod; failures return
400(see Errors). - gzip: the hub gzips
/api/*JSON responses whenAccept-Encodingaccepts gzip. Negotiation is q-value aware (acceptsGzipinhub/src/web/sseCompression.ts):gzip;q=0is honored as a refusal,*counts unless agzipentry overrides it. Send a normalAccept-Encoding: gzipand decompress transparently. The SSE stream is gzip-compressed separately with per-event flush — see SSE. Source:hub/src/web/server.ts. - Several endpoints are RPC-wrapped: the hub forwards to the session's CLI process over Socket.IO and relays the result. These can fail with HTTP 200 +
{success: false, error}or with 503 — see Errors.
Interactive client API
Health
Source: hub/src/web/server.ts.
| Method & path | Request | Response |
|---|---|---|
GET /health (no auth) | — | {status: 'ok', protocolVersion: number, capabilities: {workGraph?, titleSuggestion?}} — additive capabilities, ignore unknown keys |
Sessions — list & detail
Source: hub/src/web/routes/sessions.ts; shapes SessionSchema (shared/src/schemas.ts), SessionSummary (shared/src/sessionSummary.ts).
| Method & path | Request | Response |
|---|---|---|
GET /api/sessions | Query: limit? (1–500), order?=updatedAt | {sessions: (SessionSummary & {futureScheduledMessageCount, nextScheduledAt})[]} |
GET /api/sessions/:id | — | {session: Session} (full record incl. metadata, agentState, todos, versions) |
Default list order: globalPinned → pinned → active → pending-request count → updatedAt desc; order=updatedAt gives pure recency. List badges come from SessionSummary.pendingRequestsCount (authoritative total) and pendingRequests (capped at 5, oldest-first) — do not derive counts from pendingRequests.length.
Sessions — lifecycle
Source: hub/src/web/routes/sessions.ts; request schemas in shared/src/apiTypes.ts.
| Method & path | Request | Response |
|---|---|---|
POST /api/sessions/:id/resume | {permissionMode?} (ResumeSessionRequestSchema) | {type: 'success', sessionId} |
POST /api/sessions/:id/reopen | {} | {ok: true, sessionId, resumed: boolean, cursorSessionProtocol?} (ReopenSessionResponseSchema); 422 {error, missing[]} if metadata is incomplete |
POST /api/sessions/:id/abort | {} | {ok: true} (active sessions only) |
POST /api/sessions/:id/archive | {} | {ok: true} or {ok: true, alreadyArchived: true}; 409 for a plain inactive session |
DELETE /api/sessions/:id | — | {ok: true}; 409 while active (archive first) |
PATCH /api/sessions/:id | {name} (1–255 chars) | {ok: true} (rename) |
PATCH /api/sessions/:id/summary | {text} (1–255 chars) | {ok: true} |
PUT /api/sessions/:id/pin | {mode: 'none'|'project'|'global'} | {ok: true} |
POST /api/sessions/:id/switch | {} | {ok: true} — hands terminal-controlled session over to remote control |
POST /api/sessions/:id/clear | {} | {sessionId} — shared sessions only; resume inactive sessions through the Runner first, then create a new root; navigate only the initiating view |
POST /api/sessions/:id/title-suggestion | — | {title}; errors pass through 422/429/502/503 |
GET /api/sessions/:id/slash-commands | — | SlashCommandsResponse {success, commands?, error?} |
GET /api/sessions/:id/skills | — | SkillsResponse-shaped {success, ...} |
resume / reopen may return a different sessionId
Both endpoints return the id of the session that now carries the conversation — which may differ from the id you called them on (fresh spawn under a new id; the old row is superseded). Clients must migrate composer drafts and replace navigation to the returned id. Reference: web/src/routes/sessions/followSupersedingSession.ts; the durable link also appears as metadata.supersededBySessionId on the old session.
Additional session APIs without a current native UI: POST /api/sessions/:id/fork {messageLocalId?} → {sessionId}, POST /api/sessions/:id/rewind {messageLocalId} → {success: true}, GET /api/sessions/:id/export (413 when too large). Availability also depends on session history capabilities.
Messages
Source: hub/src/web/routes/messages.ts; schemas MessagesQuerySchema, SendMessageRequestSchema, QueuedStateRequestSchema (shared/src/apiTypes.ts), responses CancelMessageResponseSchema, SteerQueuedMessageResponseSchema (shared/src/schemas.ts). Unknown steer delivery is durable and user-resolvable; native clients must implement the same transitions.
| Method & path | Request | Response |
|---|---|---|
GET /api/sessions/:id/messages | Query: limit? (1–200, default 50), cursor pairs beforeSeq+beforeAt | afterSeq+afterAt (+ optional untilSeq+untilAt, epoch with after) | MessagesResponse {messages: DecryptedMessage[], page: {direction, limit, epoch, reset, nextBefore*/nextAfter*, snapshotHead*, hasMore}} — full cursor semantics in Pagination |
POST /api/sessions/:id/messages | {text, localId?, attachments?, scheduledAt?, deliveryMode?: 'queue'|'steer'} — text or attachments required; scheduledAt requires localId, must be ≤ 7 days out, excludes attachments and steer | {ok: true} — the message itself arrives via SSE (message-received), reconciled by localId |
DELETE /api/sessions/:id/messages/:messageId | — | {status: 'cancelled', localId} | {status: 'invoked', message} | {status: 'busy', localId} (cancel; busy = native delivery/removal is unresolved) |
POST /api/sessions/:id/messages/:messageId/steer | — | {status: 'steered', localId} | {status: 'invoked', message} | {status: 'failed', error, localId} |
POST /api/sessions/:id/messages/:messageId/retry | — | {status: 'retried', localId} | {status: 'already-queued', localId} | {status: 'retry-unavailable', localId} | {status: 'invoked', message} | {status: 'not-found'} — explicit retry only; never automatic replay |
POST /api/sessions/:id/messages/queued-state | {localIds: string[]} (≤ 1000, deduped) | {queuedLocalIds: string[], indeterminateLocalIds: string[], invokedLocalMessages: [{localId, invokedAt}]} — resync after reconnect; preserve indeterminate rows without auto-replaying them |
The hub stamps sentFrom: 'webapp' on REST-sent messages server-side; the request body has no such field.
Permissions
Source: hub/src/web/routes/permissions.ts. Pending requests are not messages: they live in session.agentState.requests (keyed by request id) and move to agentState.completedRequests when resolved — schemas AgentStateRequestSchema / AgentStateCompletedRequestSchema in shared/src/schemas.ts.
Shared Codex completed requests may have status: 'resolved': native completion is known, but the winner/answer is not. Render a neutral status, not Approved or an inferred selection. A successful approve HTTP response only submitted a candidate. canceled can instead mean withdrawal after a transport disconnect.
| Method & path | Request | Response |
|---|---|---|
POST /api/sessions/:id/permissions/:requestId/approve | {mode?, allowTools?: string[], decision?, answers?} | {ok: true} |
POST /api/sessions/:id/permissions/:requestId/deny | {decision?} | {ok: true} |
decision:'approved' | 'approved_for_session' | 'denied' | 'abort'.mode: optionally switch permission mode while approving; validated against the session flavor.answershas two formats, matching the requesting tool: flatRecord<string, string[]>(AskUserQuestion) or nestedRecord<string, {answers: string[]}>(request_user_input). Both routes 404 withRequest not foundif the id is not currently pending, and 409session_inactivewhen the session is inactive.
Session config — mode / model / effort (per flavor)
Source: hub/src/web/routes/sessions.ts; flavor gates in shared/src/modes.ts (getPermissionModesForFlavor) and shared/src/flavors.ts (supportsModelChange, supportsEffort). Wrong-flavor calls return 400; several are additionally rejected with 409 when the session is terminal-controlled (agentState.controlledByUser === true).
| Method & path | Request | Applies to |
|---|---|---|
POST /api/sessions/:id/permission-mode | {mode: PermissionMode} | All flavors except pi and dsh (per-flavor allowed sets in modes.ts) |
POST /api/sessions/:id/model | {model: string | {provider, modelId} | null} | Flavors with supportsModelChange (not dsh); remote-only for codex/cursor/grok |
POST /api/sessions/:id/effort | {effort: string | null} | claude, grok, pi (supportsEffort) |
POST /api/sessions/:id/model-reasoning-effort | {modelReasoningEffort: string | null} | codex, opencode (remote-only) |
POST /api/sessions/:id/service-tier | {serviceTier: 'fast' | 'standard'} | codex (remote-only) |
POST /api/sessions/:id/collaboration-mode | {mode: 'default' | 'plan'} | codex (remote-only) |
POST /api/sessions/:id/copilot-agent-mode | {mode} | copilot (remote-only) |
metadata.capabilities.concurrentClients === true: ignore the legacy agentState.controlledByUser input/settings gate. Shared keepalives have no ownership mode. /switch returns HTTP 409 with code: 'control_mode_not_applicable'; do not offer takeover. The remote-only Codex configuration restrictions above do not apply to these sessions. Shared Codex rejects safe-yolo even though it remains in the historical Codex permission-mode schema; do not offer it for concurrent sessions.
Shared /clear has no global supersededBySessionId change; clients other than the caller stay on the original thread. Fork may return an already-bound shared child; the hub must not spawn a second engine. Use advertised history capabilities: shared Codex currently supports fork, not in-place rewind.
Shared Codex plan execution: POST /api/sessions/:id/codex/plan/implement with {planId} returns {ok: true} after native queue acceptance. Use the current agentState.codexPlanProposalId to match the transcript proposal's tool-call id; null withdraws actions without removing content. The CLI validates the latest completed Plan-mode turn, switches to Default, and queues Implement the plan. with a stable submission id. Repeating an accepted action does not enqueue it again. This is separate from tool permissions. Errors include HTTP 409 (stale_plan / unavailable), 502 (failed) and 503 (indeterminate); error bodies have {ok: false, code, error}. Do not automatically resend after an unconfirmed result. "Continue planning" is a local composer-focus action and does not submit a native approval or message.
The configuration routes in the table above respond {ok: true}; apply-failures return 409 with a message. Model/effort catalogs (RPC-wrapped; all return {success, ...} \| {success: false, error}):
| Method & path | Notes |
|---|---|
GET /api/sessions/:id/codex-models, /opencode-models, /cursor-models, /grok-models, /copilot-models, /pi-models | Active session of the matching flavor; 400 otherwise |
GET /api/sessions/:id/opencode-reasoning-effort-options, /grok-reasoning-effort-options | Same pattern |
GET /api/machines/:id/agy-models, /pi-models, /codex-models, /cursor-models | Machine-level (pre-spawn pickers). agy-models takes ?refresh=true to skip the machine's cached catalog, and may answer success: true with an advisory error (see Errors) |
GET /api/machines/:id/opencode-models?cwd=, /grok-models?cwd=, /copilot-models?cwd= | cwd query required (400 without) |
Machines & spawning
Source: hub/src/web/routes/machines.ts; schemas SpawnSessionRequestSchema, MachineListDirectoryRequestSchema, MachinePathsExistsRequestSchema, RenameMachineRequestSchema (shared/src/apiTypes.ts), MachineSchema (shared/src/schemas.ts).
| Method & path | Request | Response |
|---|---|---|
GET /api/machines | — | {machines: Machine[]} (online machines in the caller's namespace) |
PATCH /api/machines/:id | {displayName} (trimmed; ≤ 64 chars; empty clears back to hostname) | {ok: true} |
GET /api/machines/:id/agent-availability | — | {agents: {agent, available, reason?: 'not_found'|'invalid_configuration'}[]}; 409 runner_upgrade_required on old runners |
POST /api/machines/:id/spawn | {directory, agent?, model?, effort?, modelReasoningEffort?, yolo?, permissionMode?, sessionType?: 'simple'|'worktree', worktreeName?, serviceTier?, collaborationMode?, copilotAgentMode?, startingMode?: 'remote'|'pty'} | {type: 'success', sessionId} | {type: 'error', message, code?, agent?} (agy accepts only remote) |
POST /api/machines/:id/list-directory | {path, includeHidden?} | {success, entries?: (DirectoryEntry & {isGitRepo?})[], error?} |
POST /api/machines/:id/paths/exists | {paths: string[]} (≤ 1000) | {exists: Record<string, boolean>, outsideWorkspaceRoots?: string[]} |
POST /api/machines/:id/restart-runner | {} | {message}; errors carry code: 'machine_not_found' | 'machine_offline' |
Note the spawn response is discriminated on type, not HTTP status — a failed spawn is still HTTP 200. Stable spawn failure codes are agent_unavailable, runner_upgrade_required, and outside_workspace_roots. Clients should fetch Agent availability when the machine is selected and use that result to drive the form. The runner performs the authoritative availability check as part of spawning, covering changes after the form-level query without requiring a duplicate client RPC. Availability checks executables and static runner configuration only; it does not execute the Agent or verify account/login state.
Directory listing and spawning use the same workspace-root policy. With no metadata.workspaceRoots, paths are unrestricted beyond the runner account's filesystem permissions; metadata.homeDir is only a suggested starting location. Explicit roots restrict both operations, after canonical symlink resolution. Listings classify allowed directory symlinks as directories and omit links whose targets escape the configured roots. Dot-prefixed entries require includeHidden: true. Autocomplete clients can suggest known workspace roots from metadata while a user types their prefix, without listing a root's out-of-bounds parent.
Git & files (RPC-wrapped)
Source: hub/src/web/routes/git.ts. Git endpoints return the raw command output — GitCommandResponse {success, stdout?, stderr?, exitCode?, error?} — and the client parses stdout itself (reference parsers: web/src/lib/gitParsers.ts).
| Method & path | Request | Response |
|---|---|---|
GET /api/sessions/:id/git-status | — | GitCommandResponse (raw git status stdout) |
GET /api/sessions/:id/git-diff-numstat | Query: staged=true|false | GitCommandResponse (raw git diff --numstat stdout) |
GET /api/sessions/:id/git-diff-file | Query: path (required), staged? | GitCommandResponse (raw unified diff) |
GET /api/sessions/:id/file | Query: path (required) | {success, content?, size?, modified?, error?} — content is base64 (decode before display; web ref: web/src/routes/sessions/file.tsx) |
GET /api/sessions/:id/files | Query: query?, limit? (1–500, default 200) | {success, files: [{fileName, filePath, fullPath, fileType: 'file', size?, modified?}]} (ripgrep-backed search) |
GET /api/sessions/:id/directory | Query: path? (empty = session root) | {success, entries?: [{name, type: 'file'|'directory'|'other', size?, modified?}], error?} |
When the session has no metadata.path yet, these return HTTP 200 {success: false, error: 'Session path not available'}.
Generated images
Source: hub/src/web/routes/git.ts (same file).
| Method & path | Response |
|---|---|
GET /api/sessions/:id/generated-images/:imageId | Raw bytes with Content-Type, Content-Disposition, ETag: "<imageId>", Cache-Control: private, max-age=31536000, immutable; 404 JSON when missing |
The image id is an immutable content fingerprint, so it doubles as the ETag: send If-None-Match and the hub answers 304 without the CLI round-trip. Cache aggressively (iOS: URLCache honors this automatically; Android: OkHttp cache).
Uploads (message attachments)
Source: hub/src/web/routes/sessions.ts (UploadFileRequestSchema).
| Method & path | Request | Response |
|---|---|---|
POST /api/sessions/:id/upload | JSON {filename, content, mimeType} — content is base64; decoded size limit 50 MB → 413 | {success, path?, error?} — pass the resulting metadata in attachments of send-message |
POST /api/sessions/:id/upload/delete | {path} | {success, error?} |
Uploads are JSON+base64, not multipart. Both require an active session.
Scratchlist
Source: hub/src/web/routes/sessions.ts (scratchlist section); schemas ScratchlistEntryCreateRequestSchema, ScratchlistEntryUpdateRequestSchema, caps SCRATCHLIST_MAX_ENTRIES = 200, SCRATCHLIST_MAX_TEXT_LENGTH = 10000 (shared/src/apiTypes.ts).
| Method & path | Request | Response |
|---|---|---|
GET /api/sessions/:id/scratchlist | — | {entries: ScratchlistEntry[]} ({entryId, text, createdAt, updatedAt, attachments[]}) |
POST /api/sessions/:id/scratchlist | {text, entryId?, createdAt?, attachments?} (text ≤ 10 000; text or attachments required) | 201 {entry}; 200 {entry} when entryId already exists (idempotent retry); 409 code: 'scratchlist_at_cap' at 200 entries |
PUT /api/sessions/:id/scratchlist/:entryId | {text?, attachments?} (at least one) | {entry} |
DELETE /api/sessions/:id/scratchlist/:entryId | — | {ok: true} |
GET /api/sessions/:id/scratchlist/limits | — | {limits} (attachment size/count/byte budgets) |
POST /api/sessions/:id/scratchlist/upload | JSON {filename, content (base64), mimeType} | {success, attachment}; 413 code: 'scratchlist_attachment_too_large' |
GET /api/sessions/:id/scratchlist/attachments/:attachmentId | — | Raw bytes (Content-Type from stored metadata) |
DELETE /api/sessions/:id/scratchlist/attachments/:attachmentId | — | {ok: true}; 409 code: 'scratchlist_attachment_in_use' while referenced by an entry |
Mutations bump scratchlistUpdatedAt in the session's SSE patch — use it as a refetch trigger, not as data.
Voice dictation (the one multipart endpoint)
Source: hub/src/web/routes/voice.ts.
| Method & path | Request | Response |
|---|---|---|
GET /api/voice/transcription/providers | — | {providers: [{id, label, modes}]} — only providers whose keys are configured on the hub |
POST /api/voice/transcription | multipart/form-data: file (audio, ≤ 25 MB, audio/* or webm/mp4), provider (openai|elevenlabs|deepgram|groq|openai-compatible), mode = standard, language? (BCP-47-ish, ≤ 35 chars) | {text, language?}; 413 body too large, 400 bad field |
Native apps currently use standard transcription only. Realtime dictation, voice-assistant tokens and WebSocket proxies are used by the web voice UI; see Other hub surfaces.
Usage & storage (owner-only)
Source: hub/src/web/routes/usage.ts, hub/src/web/routes/storage.ts. Both 403 unless namespace is default (Auth → Namespaces).
| Method & path | Request | Response |
|---|---|---|
GET /api/usage/summary | Query: range=7d|30d|all (default 7d), timeZone (IANA, validated) | UsageSummaryResponse {range, totals, daily[], byAgent[], byModel[], updatedAt} |
GET /api/storage/sqlite | — | {path, databaseBytes, walBytes, shmBytes, totalBytes} |
Devices (Android / iOS push)
Source: hub/src/web/routes/devices.ts; full push contract in native-companion-contract.md.
| Method & path | Request | Response |
|---|---|---|
POST /api/devices/register | {token, platform: 'phone'|'wear'|'ios', deviceId, pushKey?} (deviceId: any stable 1–128-char install id) | {ok: true} (upsert) |
DELETE /api/devices/register | {token} | {ok: true} |
pushKey is base64 of exactly 32 bytes. It is required for iOS; phone registrations may omit it for direct FCM, but Android relay delivery requires it and the current Android app supplies it. Supplied phone keys are validated; Wear ignores the field. Invalid registration bodies return 400. Upserts are keyed by (namespace, deviceId, platform). The wear protocol value does not imply a Wear OS app is included in this repository.
Visibility
Source: hub/src/web/routes/events.ts (POST /visibility).
| Method & path | Request | Response |
|---|---|---|
POST /api/visibility | {subscriptionId, visibility: 'visible'|'hidden'} | {ok: true}; 404 when the SSE subscription is gone |
subscriptionId comes from the SSE connection-changed event (SSE). Report foreground/background transitions so the hub can suppress redundant push notifications while the app is visibly connected.
Hub settings (read)
Source: hub/src/web/routes/hubSettings.ts.
| Method & path | Response |
|---|---|
GET /api/hub-settings | {sessionSummaryContract: boolean, sessionSummaryInChat: boolean} — readable by any namespace |
SSE
GET /api/events is the realtime channel — subscription params, resume handshake, and reconnect policy are specified in SSE. Its gzip behavior differs from the JSON endpoints (streaming compression with per-event flush), also covered there.
Other hub surfaces
These surfaces exist on the hub but are not exposed by the current native apps. This is a feature inventory, not a protocol-version ban. Client-type restrictions and owner authorization still apply; the CLI plane below is internal and must never be used by clients.
| Area | Paths | Current use or restriction |
|---|---|---|
| Codex Desktop import | /api/codex/* (hub/src/web/routes/codexDesktop.ts) | Desktop-import tooling |
| Pi session import | /api/pi/*, /api/sessions/:id/pi-* (hub/src/web/routes/piSessions.ts, sessions.ts) | Import tooling (the pi-models catalog above is the one exception) |
| Work graph | /api/work-graph/* (hub/src/web/routes/workGraph.ts) | Web-only feature |
| Web Push | /api/push/* (hub/src/web/routes/push.ts) | Browser Push API; natives use /api/devices/register (Android/iOS push contract) |
| Hub settings write | PUT /api/hub-settings | Owner-only hub administration |
| Telegram | POST /api/bind | Telegram Mini App binding only |
| Web voice | /api/voice/token, /voices, /backend, /gemini-token, /qwen-token, /qwen-ws, /gemini-ws, /transcription/realtime-token, /telemetry, credentials endpoints | Realtime assistant/dictation and provider administration; native apps use standard dictation |
| Cursor maintenance | /api/sessions/:id/migrate-to-acp, /cursor-chat-store | Desktop store migration |
| Session export | GET /api/sessions/:id/export | Whole-session export through the web; separate from Android's full-text reader file export |
| CLI plane | /cli/* (hub/src/web/routes/cli.ts) | Forbidden for clients — internal CLI↔hub surface; authenticates with the raw access token instead of a JWT and bypasses the client middleware. Never call it from a client, and never send the access token as a bearer anywhere except POST /api/auth's JSON body |