Files
hapi/docs/api/client-contract/rest.md
T

19 KiB
Raw Blame History

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.

Conventions

  • All paths below are relative to the hub base URL. Everything under /api requires Authorization: Bearer <JWT> (Auth).
  • Path params (:id, :messageId, …) must be URL-encoded (the web client uses encodeURIComponent throughout).
  • Request bodies are JSON (content-type: application/json) with one exception: POST /api/voice/transcription is multipart/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 when Accept-Encoding accepts gzip. Negotiation is q-value aware (acceptsGzip in hub/src/web/sseCompression.ts): gzip;q=0 is honored as a refusal, * counts unless a gzip entry overrides it. Send a normal Accept-Encoding: gzip and 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.

Tier 1 — required for v1 clients

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/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, ...}

::: warning 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. :::

Optional for v1 (endpoints exist; the v1 native scope does not require them): POST /api/sessions/:id/fork {messageLocalId?} → {sessionId}, POST /api/sessions/:id/rewind {messageLocalId} → {success: true}, GET /api/sessions/:id/export (413 when too large).

Messages

Source: hub/src/web/routes/messages.ts; schemas MessagesQuerySchema, SendMessageRequestSchema, QueuedStateRequestSchema (shared/src/apiTypes.ts), responses CancelMessageResponseSchema, SteerQueuedMessageResponseSchema (shared/src/schemas.ts).

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} (cancel a queued message; invoked = too late)
POST /api/sessions/:id/messages/:messageId/steer — {status: 'steered', localId} | {status: 'invoked', message} | {status: 'failed', error, localId}
POST /api/sessions/:id/messages/queued-state {localIds: string[]} (≤ 1000, deduped) {queuedLocalIds: string[], invokedLocalMessages: [{localId, invokedAt}]} — resync optimistic sends after reconnect

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.

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.
  • answers has two formats, matching the requesting tool: flat Record<string, string[]> (AskUserQuestion) or nested Record<string, {answers: string[]}> (request_user_input). Both routes 404 with Request not found if the id is not currently pending, and 409 session_inactive when 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 (per-flavor allowed sets in modes.ts)
POST /api/sessions/:id/model {model: string | {provider, modelId} | null} All flavors (supportsModelChange is true for every current flavor); 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)

All 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)
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}
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} (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>}
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.

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

The realtime-token, voice-assistant token, and WebSocket-proxy endpoints under /api/voice/* belong to the live voice assistant — out of scope for v1.

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 (FCM 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', deviceId} (deviceId: any stable 1–128-char install id) {ok: true} (upsert)
DELETE /api/devices/register {token} {ok: true}

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.

Tier 2 — out of scope for v1

These exist on the hub but v1 native clients must not implement or call them:

Area Paths Why out of scope
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 (FCM)
Hub settings write PUT /api/hub-settings Owner-only hub administration
Telegram POST /api/bind Telegram Mini App binding only
Voice assistant /api/voice/token, /voices, /backend, /gemini-token, /qwen-token, /qwen-ws, /gemini-ws, /transcription/realtime-token, /telemetry, credentials endpoints Realtime assistant, not v1 dictation
Cursor maintenance /api/sessions/:id/migrate-to-acp, /cursor-chat-store Desktop store migration
Session export GET /api/sessions/:id/export Feeds the share/export feature, excluded from v1
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