From 16ebba12e8ad3fef4fde300d6464279b2da75f75 Mon Sep 17 00:00:00 2001 From: weishu Date: Mon, 17 Aug 2026 11:15:00 +0800 Subject: [PATCH] docs(api): add native client contract (auth, rest, errors) --- docs/.vitepress/config.ts | 20 ++- docs/api/client-contract/auth.md | 137 ++++++++++++++++ docs/api/client-contract/errors.md | 67 ++++++++ docs/api/client-contract/index.md | 46 ++++++ docs/api/client-contract/rest.md | 244 +++++++++++++++++++++++++++++ 5 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 docs/api/client-contract/auth.md create mode 100644 docs/api/client-contract/errors.md create mode 100644 docs/api/client-contract/index.md create mode 100644 docs/api/client-contract/rest.md diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 94a4373d..d00c6c1b 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -5,6 +5,12 @@ export default defineConfig({ description: 'Control your AI agents from anywhere', base: '/docs/', + // Temporary: client-contract sse/pagination/messages are being written in + // parallel (WP K2). Remove this once those pages land. + ignoreDeadLinks: [ + /^\.\/(sse|pagination|messages)(#.*)?$/ + ], + head: [ ['link', { rel: 'icon', href: '/docs/favicon.ico' }], ], @@ -52,7 +58,19 @@ export default defineConfig({ { text: 'API', items: [ - { text: 'Native Companion Contract', link: '/api/native-companion-contract' } + { text: 'Native Companion Contract', link: '/api/native-companion-contract' }, + { + text: 'Client contract', + items: [ + { text: 'Overview', link: '/api/client-contract/' }, + { text: 'Auth', link: '/api/client-contract/auth' }, + { text: 'REST', link: '/api/client-contract/rest' }, + { text: 'SSE', link: '/api/client-contract/sse' }, + { text: 'Pagination', link: '/api/client-contract/pagination' }, + { text: 'Messages', link: '/api/client-contract/messages' }, + { text: 'Errors', link: '/api/client-contract/errors' } + ] + } ] } ], diff --git a/docs/api/client-contract/auth.md b/docs/api/client-contract/auth.md new file mode 100644 index 00000000..4b1bb1d0 --- /dev/null +++ b/docs/api/client-contract/auth.md @@ -0,0 +1,137 @@ +# Auth & pairing + +How a native client obtains and maintains credentials for one hub. A client may be paired with multiple hubs; everything below is **per hub URL**. + +## Credential model + +Source of truth: `hub/src/config/cliApiToken.ts`, `hub/src/web/routes/auth.ts`. + +| Credential | Lifetime | Where it comes from | What it's for | +|------------|----------|--------------------|---------------| +| **Access token** | Long-lived (until the operator rotates `CLI_API_TOKEN`) | Pairing QR / deeplink, or typed in manually | Exchanged for a JWT via `POST /api/auth`; the client's durable secret | +| **JWT** | 4 hours | Response of `POST /api/auth` | `Authorization: Bearer` on every `/api/*` request | + +The hub's base token (`CLI_API_TOKEN`) is auto-generated on first run (32 random bytes, base64url, ~43 chars) and persisted in the hub's `settings.json`; an operator-provided env token overrides it. The configured base token never contains a `:` — the hub refuses to start otherwise (`validateCliApiToken`). + +## Pairing + +Source of truth: `hub/src/startHub.ts` (lines ~317–366), `web/src/components/settings/CompanionPairing.tsx`. + +The hub terminal (when started with `--relay`) prints two QR codes; the web app's Settings → Companion pairing screen renders the second one as well: + +| QR | Format | Params | +|----|--------|--------| +| Web direct-access | `https://app.hapi.run/?hub=&token=` | `hub`, `token` | +| Companion deeplink | `hapicompanion://bind?hub=&code=` | `hub`, `code` | + +Native clients register the `hapicompanion://` scheme and parse `bind` links: `hub` is the hub base URL, `code` is the access token. Note the param-name mismatch: the web QR carries the same value under `token=`, the companion deeplink under `code=`. A robust scanner may accept both forms; the deeplink form is the canonical one for natives. Always provide a manual fallback (type hub URL + access token) for `--relay`-less local hubs. + +## Access-token grammar + +Source of truth: `hub/src/utils/accessToken.ts`. + +``` +accessToken = base [":" namespace] +``` + +- Split on the **last** `:`. No colon → namespace defaults to `"default"`. +- After splitting, both parts must be non-empty and contain no leading/trailing whitespace, else the token is invalid. +- The whole input is trimmed before parsing. + +Clients must treat the access token as an **opaque string** and pass it through unchanged to `POST /api/auth` — never split it client-side to "normalize" it. The hub does the splitting; the namespace part selects which sessions the resulting JWT can see (see [Namespaces](#namespaces)). + +## Token exchange + +Source of truth: `hub/src/web/routes/auth.ts`. + +``` +POST /api/auth +Content-Type: application/json + +{ "accessToken": "" } +``` + +Success (200): + +```json +{ + "token": "", + "user": { "id": 1, "firstName": "Web User" } +} +``` + +Failures: `400 {"error": "Invalid body"}`, `401 {"error": "Invalid access token"}`. Request schema: `AuthRequestSchema` in `shared/src/apiTypes.ts` (the `initData` variant is Telegram-only). + +`POST /api/bind` (`hub/src/web/routes/bind.ts`) is **Telegram-only** — it binds a Telegram identity to a namespace and requires Telegram `initData`. Native clients never call it. + +## The JWT + +Source of truth: `hub/src/web/routes/auth.ts` (signing), `hub/src/web/middleware/auth.ts` (verification), `hub/src/config/jwtSecret.ts` (key). + +- HS256, signed with a hub-local 32-byte secret (`/jwt-secret.json`). +- Payload: `{ "uid": , "ns": }` plus standard `iat`/`exp`. +- **Expires 4 hours** after issue. + +Treat the token as opaque for auth purposes, but clients may base64url-decode the payload to read `exp` for proactive refresh scheduling (the web client does exactly this — `decodeJwtExpMs` in `web/src/hooks/useAuth.ts`). + +## Sending the token + +Source of truth: `hub/src/web/middleware/auth.ts`. + +- Every `/api/*` request: `Authorization: Bearer `. +- Exceptions: `/api/auth` and `/api/bind` are unauthenticated; `GET /health` is outside `/api` and unauthenticated. +- `GET /api/events` (SSE) **additionally** accepts `?token=` as a query param, for HTTP stacks whose EventSource cannot set headers. The header wins when both are present. No other endpoint accepts query-param auth. + +## Silent re-auth (401 handling) + +Reference behavior: `web/src/api/client.ts` (`request()`), `web/src/hooks/useAuth.ts` (`refreshAuth`). + +The JWT expires every 4 hours, so 401s are routine, not exceptional. The contract: + +1. On any 401 from an `/api/*` call, re-exchange the **stored access token** via `POST /api/auth`. +2. If the exchange succeeds, retry the original request **exactly once** with the new JWT. +3. If the exchange fails (or the retry 401s again), surface "signed out" and require re-pairing — the access token was rotated or revoked. + +Implementation notes (all present in the web reference and recommended for natives): + +- **Single-flight** the refresh: concurrent 401s must share one in-flight `POST /api/auth` promise, not race N exchanges (`refreshPromiseRef` in `useAuth.ts`). +- Throttle failed refresh attempts (web: 15 s between attempts) so a dead hub doesn't cause a refresh storm. +- Optionally refresh proactively: the web schedules a refresh 60 s before `exp` and on app-foreground when remaining TTL < 60 s. This keeps the SSE connection (which authenticates once, at connect time) from dying mid-stream with a stale token on reconnect. + +## Namespaces + +Source of truth: `hub/src/web/middleware/auth.ts` (sets `namespace` from `ns`), `hub/src/web/routes/guards.ts`, `hub/src/web/routes/{usage,storage,hubSettings,voice}.ts`. + +Every request executes in the JWT's namespace (`ns` claim, derived from the access-token suffix). Sessions and machines are namespace-scoped: a session in another namespace answers `403 Session access denied` / `404 Session not found` per the guard logic. + +`ns === "default"` is the **hub owner**. Owner-only surfaces (403 for any other namespace): + +| Endpoint | Check | +|----------|-------| +| `GET /api/usage/summary` | `hub/src/web/routes/usage.ts` | +| `GET /api/storage/sqlite` | `hub/src/web/routes/storage.ts` | +| `PUT /api/hub-settings` (write; read is open to all namespaces) | `hub/src/web/routes/hubSettings.ts` | +| `GET`/`PUT /api/voice/transcription/credentials` | `hub/src/web/routes/voice.ts` | + +Clients should hide the usage/storage screens entirely when the paired namespace is not `default` (the namespace is known client-side: it's the part after the last `:` of the access token, or `default`). + +## Credential storage guidance + +- Store the **access token** in platform-secure storage: iOS Keychain, Android `EncryptedSharedPreferences` (behind an interface so the mechanism can be swapped). Never plain files, never logs. +- Key credentials **per hub base URL** (normalized), since a client can pair with several hubs. Web reference: localStorage key `hapi_access_token::` (`web/src/hooks/useAuth.ts`, `web/src/components/settings/CompanionPairing.tsx`). +- The JWT is a cache, not a secret worth keeping: it is fine to hold it in memory only and re-exchange on cold start. If persisted (to save one round-trip at launch), store it alongside the access token with the same protection. +- On unpair/sign-out: delete both credentials, and unregister FCM (`DELETE /api/devices/register`) first while you still hold a valid JWT. + +## 401 error bodies + +All are JSON with an `error` string; none carry a `code` field except Telegram's `not_bound` (which reuses `error` as the discriminator — natives never see it): + +| Origin | Body | Meaning | +|--------|------|---------| +| Middleware, any `/api/*` | `{"error": "Missing authorization token"}` | No bearer header (and no `?token=` on `/api/events`) | +| Middleware, any `/api/*` | `{"error": "Invalid token"}` | JWT signature/expiry verification failed → run silent re-auth | +| Middleware, any `/api/*` | `{"error": "Invalid token payload"}` | JWT valid but payload not `{uid, ns}` (foreign/ancient token) | +| `POST /api/auth` | `{"error": "Invalid access token"}` | Access token wrong or rotated → require re-pairing | +| `POST /api/auth` (Telegram path) | `{"error": "not_bound"}` | Telegram-only; not reachable with `accessToken` auth | + +The re-auth loop must distinguish the middleware 401s (recoverable via re-exchange) from the `/api/auth` 401 (terminal — do not loop). diff --git a/docs/api/client-contract/errors.md b/docs/api/client-contract/errors.md new file mode 100644 index 00000000..49d738a1 --- /dev/null +++ b/docs/api/client-contract/errors.md @@ -0,0 +1,67 @@ +# Errors + +Error semantics for all `/api/*` endpoints. Grounded in `hub/src/web/routes/guards.ts` and the individual route files under `hub/src/web/routes/`; reference consumer `web/src/api/client.ts` (`ApiError`, `parseErrorCode`). + +## Body shape + +Error responses are JSON: + +```json +{ "error": "Session is inactive", "code": "session_inactive" } +``` + +- `error` — human-readable message. **Never match on it**: consumers i18n it and the hub may reword it (this rule is stated in `guards.ts` itself). +- `code` — optional stable machine-readable discriminator. Clients branch on `(status, code)`. +- `issues` — present on some 400s: Zod validation details (`parsed.error.issues` or `.flatten()` output). Useful for logging, not for branching. +- A few endpoints add context fields (e.g. 413 export adds `count`/`limit`; 422 reopen adds `missing[]`). + +When no `code` is present, branch on status alone and treat the failure generically. The web reference falls back to using `error` as a pseudo-code when `code` is absent (`parseErrorCode`) — acceptable for logging, not for logic. + +## Status × code table + +| Status | `code` | Where (source) | Meaning / client action | +|--------|--------|----------------|-------------------------| +| 400 | — | All routes with Zod bodies (`{error: 'Invalid body'}`, some with `issues`) | Client bug — fix the request; do not retry | +| 400 | — | Flavor gates (`sessions.ts`: wrong-flavor model/mode endpoints) | Hide the control for this flavor | +| 400 | `scratchlist_attachment_invalid`, `scratchlist_entry_empty`, attachment-limit codes from `validateScratchlistAttachmentsForWrite` | `sessions.ts` scratchlist routes | Surface validation message | +| 401 | — | Middleware (`middleware/auth.ts`) and `POST /api/auth` (`routes/auth.ts`) | See [Auth → 401 bodies](./auth.md#401-error-bodies); middleware 401 → silent re-auth once, `/api/auth` 401 → re-pair | +| 403 | — | `guards.ts` (`Session access denied`, `Machine access denied`); owner-only routes (`usage.ts`, `storage.ts`, `hubSettings.ts`, `voice.ts`) | Namespace mismatch / not hub owner — hide the surface, don't retry | +| 403 | `access_denied` | RPC-flow results mapped in `sessions.ts` (resume/reopen/cursor-chat-store) | Same as above | +| 404 | — | `guards.ts` (`Session not found`, `Machine not found`); `permissions.ts` (`Request not found`); scratchlist entry/attachment; `events.ts` visibility (`Subscription not found`) | Stale reference — refresh the parent list | +| 404 | `session_not_found`, `machine_not_found` | Coded variants from resume/reopen/restart-runner result mapping | Same | +| 409 | `session_inactive` | `guards.ts` `requireSession(requireActive)` — send/steer/abort/approve/deny/config on an inactive session | Offer **Reopen** (the web router does exactly this on this code) | +| 409 | `scratchlist_at_cap` | `sessions.ts` scratchlist create (200-entry cap) | Show cap notice; do not retry | +| 409 | `scratchlist_attachment_in_use` | `sessions.ts` attachment delete while still referenced | Detach from entry first | +| 409 | `resume_unavailable` | `sessions.ts` resume/reopen result mapping | Session can't be resumed (e.g. unsupported state) | +| 409 | `metadata_conflict` | `sessions.ts` reopen result mapping | Refetch session, retry once at most | +| 409 | — (version conflict) | `sessions.ts` PATCH rename/summary, `machines.ts` PATCH rename — message mentions `version`/`concurrently`; **no code** | Concurrent edit — refetch and reapply | +| 409 | — | `sessions.ts` delete-while-active, archive of plain inactive row, fork/rewind refusals, remote-only config on terminal-controlled sessions (`controlledByUser`) | Surface message; refresh session state | +| 413 | — | `sessions.ts` upload (> 50 MB decoded), export too large (`{error, count, limit}`); `voice.ts` transcription (`Audio file too large`, 25 MB audio / ~26 MB body) | Reduce payload | +| 413 | `scratchlist_attachment_too_large` | `sessions.ts` scratchlist upload | Reduce attachment | +| 422 | — | `sessions.ts` reopen with incomplete metadata (`{error, missing[]}`); title-suggestion pass-through (`TitleSuggestionError`, statuses 422/429/502/503) | Not reopenable / feature unavailable | +| 429 | — | Title suggestion (provider rate limit) | Back off | +| 500 | — | Catch-all in most routes (`{error: message}`) | Log; generic failure UI | +| 502 | — | Title suggestion upstream failure; restart-runner unknown error | Retry later | +| 503 | — | `guards.ts` `requireSyncEngine` → `{error: 'Not connected'}` — hub subsystems not up (startup/shutdown window); also Telegram-disabled on `/api/auth` initData path | Retry with backoff | +| 503 | `no_machine_online` | resume/reopen/spawn-flow result mapping (`sessions.ts`) | The machine that owns the session is offline — tell the user to start the runner | +| 503 | `machine_offline` | `machines.ts` restart-runner | Same | +| 503 | `rpc_target_missing` | `machines.ts` pi/codex model catalogs (`RPC_TARGET_MISSING_ERROR_CODE` in `shared/src/rpcMethods.ts` — RPC handler unregistered or socket disconnected) | CLI-side target gone — treat as offline | + +## RPC-wrapped endpoints + +Many endpoints do not answer from hub state — the hub relays the request over Socket.IO to the session's CLI process (or the machine's runner) and forwards the result: git/file/directory/search, generated images, uploads, model catalogs, slash-commands, skills, spawn, list-directory, paths/exists. (The mode/model/effort config endpoints are RPC-backed too, but map apply-failures to 409 with a message.) Their failure modes differ from plain endpoints: + +1. **CLI reachable, command failed** → HTTP **200** with `{success: false, error}` (e.g. `runRpc` in `hub/src/web/routes/git.ts` catches RPC errors, including the 30 s RPC timeout, and returns them as a JSON envelope). Clients must check the `success` field on every RPC-shaped response; HTTP 200 alone means nothing. +2. **CLI offline / handler missing** → depends on the route: the model-catalog routes in `machines.ts` map `RpcTargetMissingError` to **503 `rpc_target_missing`**; `git.ts`-style routes fold it into the 200 `{success: false}` envelope; resume/reopen surface **503 `no_machine_online`**. +3. **Hub subsystems not up** → **503 `Not connected`** from `requireSyncEngine` (brief startup/shutdown window). + +Practical rule: treat `success: false`, 503 `rpc_target_missing`, and 503 `no_machine_online` as the same user-facing condition — "the computer running this session is not reachable" — with the raw `error` string available in a details view. + +## Retry guidance + +| Class | Retry? | +|-------|--------| +| 400 / 403 / 404 / 409 / 413 / 422 | No (fix input, refresh state, or hide surface) | +| 401 (middleware) | Once, after silent re-auth ([Auth](./auth.md#silent-re-auth-401-handling)) | +| 429 / 502 / 503 | Yes, with backoff | +| 200 `{success: false}` | Manual retry only (user-initiated) — the CLI answered and said no | diff --git a/docs/api/client-contract/index.md b/docs/api/client-contract/index.md new file mode 100644 index 00000000..952ac7c8 --- /dev/null +++ b/docs/api/client-contract/index.md @@ -0,0 +1,46 @@ +# Native client contract + +**Audience:** Implementers of native HAPI clients — the iOS app (`ios/`), the Android app (`android/`), and any other non-web client that talks to a hub's client API. These pages are the primary spec for that work: every claim is grounded in hub/web source, and each section names its source file so implementers (human or AI agent) can verify against code. + +**Scope:** The HTTP contract between a client and one hub — pairing and auth, REST endpoints, SSE streaming, message pagination, message decoding, and error semantics. A client using only this contract can replicate the web app's core feature set over **REST + SSE alone** (no Socket.IO — that transport is CLI↔hub internal). + +## Pages + +| Page | Contents | +|------|----------| +| [Auth](./auth.md) | Pairing deeplink, access-token grammar, JWT exchange, silent re-auth, namespaces, credential storage | +| [REST](./rest.md) | Endpoint tables (v1-required and out-of-scope), request/response shapes, gzip negotiation | +| [SSE](./sse.md) | `GET /api/events` stream: subscription modes, resume handshake, event ids, reconnect policy | +| [Pagination](./pagination.md) | Message window: composite cursors, epoch reset, optimistic-send reconciliation | +| [Messages](./messages.md) | `DecryptedMessage.content` decoding tree (`codex` / `output` / `event` families) | +| [Errors](./errors.md) | `{status, code}` table, error body shapes, RPC-wrapped failure modes | + +## Versioning + +Source of truth: `hub/src/web/server.ts` (`/health` route), `shared/src/version.ts`. + +`GET /health` requires no auth and returns: + +```json +{ + "status": "ok", + "protocolVersion": 1, + "capabilities": { + "workGraph": true, + "titleSuggestion": false + } +} +``` + +- `protocolVersion` is the wire-protocol generation (`PROTOCOL_VERSION` in `shared/src/version.ts`, currently `1`). A client built against this contract targets version 1 and should surface an "update required" state if it ever sees a higher value. +- `capabilities` is **additive**: new hub features appear as new keys. Clients must ignore unknown keys and treat missing keys as "not supported". Feature-gate on capability keys, never on hub build versions. + +## Executable spec: golden fixtures + +The prose in [Messages](./messages.md) describes the decoding tree, but the *normative* artifact is `shared/fixtures/` — machine-generated golden files produced from the web implementation's chat pipeline (`web/src/chat/`). A native client's protocol module must reproduce those fixtures exactly; CI regenerates them whenever the web pipeline changes, so drift is caught automatically. + +`shared/fixtures/` is a companion deliverable of this contract and may not exist yet when you first read this — the fixture generator and batches land in later work packages of the same track. Until then, `web/src/chat/` itself is the reference implementation. + +## Relationship to the companion push contract + +[`docs/api/native-companion-contract.md`](../native-companion-contract.md) is the **FCM push contract**: device registration (`POST /api/devices/register`) and the outbound push payload the hub sends through Firebase. It predates this contract and is unchanged. A native client implements *both*: this contract for everything interactive, the companion contract for background push. Where the two overlap (auth, send-message, approve/deny), this contract is the more detailed spec. diff --git a/docs/api/client-contract/rest.md b/docs/api/client-contract/rest.md new file mode 100644 index 00000000..067fe997 --- /dev/null +++ b/docs/api/client-contract/rest.md @@ -0,0 +1,244 @@ +# 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 ` ([Auth](./auth.md)). +- 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](./errors.md)). +- **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](./sse.md). 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](./errors.md#rpc-wrapped-endpoints). + +## 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](./pagination.md) | +| `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` (AskUserQuestion) or nested `Record` (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}` | +| `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: ""`, `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](./auth.md#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`](../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](./sse.md)). 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](./sse.md). 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 |