> ## Documentation Index
> Fetch the complete documentation index at: https://www.plungeai.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# HTTP API reference

> oceancode serve runs the engine as a long-lived HTTP server, by default on http://127.0.0.1:4096 (--port and --host change it; the default pager shape takes --listen <host:port>). Every route below is relative to that base URL. HTTP basic auth is off until OC_SERVER_PASSWORD is set; then it …


`oceancode serve` runs the engine as a long-lived HTTP server, by default on `http://127.0.0.1:4096` (`--port` and `--host` change it; the default pager shape takes `--listen <host:port>`). Every route below is relative to that base URL. HTTP basic auth is off until `OC_SERVER_PASSWORD` is set; then it covers every route, SSE included, with the username from `OC_SERVER_USERNAME` (default `oceancode`). A non-loopback bind refuses to start without the password. Events stream over SSE: `/event` serves the v1 frames, `/api/event` and `/global/event` the v2 payload-wrapped envelope. Flags and variables: [Headless and server](/oceancode/headless-and-server).

```bash
curl -u "oceancode:$OC_SERVER_PASSWORD" http://127.0.0.1:4096/api/health
```

## v2 — /api

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/api/agent` | `v2.agent.list` | built-in + config agents. |
| GET | `/api/command` | `v2.command.list` | — |
| PATCH | `/api/credential/{credentialID}` | `v2.credential.update` | (relabel). The contract declares 204. |
| DELETE | `/api/credential/{credentialID}` | `v2.credential.remove` | The contract declares 204. |
| GET | `/api/event` | — | The v2 `GlobalEvent` stream shared by `GET /api/event` (`v2.event.subscribe`) and `GET /global/event` (`global.event`). |
| GET | `/api/fs/find` | `v2.fs.find` | ranked recursive search. |
| GET | `/api/fs/list` | `v2.fs.list` | the direct children of a directory. |
| GET | `/api/fs/read/{*path}` | `v2.fs.read` | The wildcard path is the file to read; the body is the raw bytes under the detected mime. |
| GET | `/api/health` | `v2.health.get` | — |
| GET | `/api/integration` | `v2.integration.list` | — |
| GET | `/api/integration/attempt/{attemptID}` | `v2.integration.attempt.status` | An unknown attempt is a 404 `NotFoundError`. |
| DELETE | `/api/integration/attempt/{attemptID}` | `v2.integration.attempt.cancel` | The contract declares 204. |
| POST | `/api/integration/attempt/{attemptID}/complete` | `v2.integration.attempt.complete` | The contract declares 204. |
| GET | `/api/integration/{integrationID}` | `v2.integration.get` | — |
| POST | `/api/integration/{integrationID}/connect/key` | `v2.integration.connect.key` | The contract declares 204. |
| POST | `/api/integration/{integrationID}/connect/oauth` | `v2.integration.connect.oauth` | — |
| GET | `/api/location` | `v2.location.get` | The `LocationInfo` schema requires a resolved `{directory, project}` (project carries id + directory), so it is built via the shared helper rather than a bare `{directory}`. |
| GET | `/api/model` | `v2.model.list` | — |
| GET | `/api/permission/request` | — | the pending requests wrapped in `{location, data}`. |
| GET | `/api/permission/saved` | — | the always-allow rules as `PermissionSavedInfo`s wrapped in `{data}`. |
| DELETE | `/api/permission/saved/{id}` | — | drops a saved rule addressed by the composite `"<permission>:<pattern>"` id. |
| GET | `/api/provider` | `v2.provider.list` | — |
| GET | `/api/provider/{providerID}` | `v2.provider.get` | — |
| GET | `/api/pty` | `v2.pty.list` | — |
| POST | `/api/pty` | `v2.pty.create` | — |
| GET | `/api/pty/{ptyID}` | `v2.pty.get` | — |
| PUT | `/api/pty/{ptyID}` | `v2.pty.update` | — |
| DELETE | `/api/pty/{ptyID}` | `v2.pty.remove` | The v2 contract is 204. |
| GET | `/api/pty/{ptyID}/connect` | `v2.pty.connect` | (WebSocket upgrade). |
| POST | `/api/pty/{ptyID}/connect-token` | `v2.pty.connectToken` | — |
| GET | `/api/question/request` | — | the `{location, data}` envelope over the request list. |
| GET | `/api/reference` | `v2.reference.list` | — |
| GET | `/api/session` | `v2.session.list` | — |
| POST | `/api/session` | `v2.session.create` | — |
| GET | `/api/session/active` | `v2.session.active` | The sessions with an in-flight coordinator drain, keyed by id. |
| GET | `/api/session/{sessionID}` | `v2.session.get` | — |
| POST | `/api/session/{sessionID}/agent` | `v2.session.switchAgent` | Publishes the durable agent-switched event the projector already handles. |
| POST | `/api/session/{sessionID}/compact` | `v2.session.compact` | Triggers a compaction turn via `Runner::compact` (the same summarize-then-replace path the runner's own overflow recovery uses). 204 on success; errors map through the shared session-error writer. |
| GET | `/api/session/{sessionID}/context` | `v2.session.context` | The active post-compaction context window, wrapped in `{data:[…]}`. |
| POST | `/api/session/{sessionID}/interrupt` | `v2.session.interrupt` | the v2 alias of the v1 `session.abort` semantics. |
| GET | `/api/session/{sessionID}/message` | `v2.session.messages` | — |
| GET | `/api/session/{sessionID}/message/{messageID}` | `v2.session.message` | — |
| POST | `/api/session/{sessionID}/model` | `v2.session.switchModel` | — |
| GET | `/api/session/{sessionID}/permission` | — | the pending requests scoped to one session. |
| POST | `/api/session/{sessionID}/permission` | — | evaluates the requested action/resources against the session's ruleset synchronously (no blocking wait) and returns `{id, effect}`, mirroring the v2 contract's non-interactive evaluation shape. |
| GET | `/api/session/{sessionID}/permission/{requestID}` | — | a single pending request. |
| POST | `/api/session/{sessionID}/permission/{requestID}/reply` | — | — |
| GET | `/api/session/{sessionID}/permission/mode` | — | the session's mode + lockout. |
| POST | `/api/session/{sessionID}/permission/mode` | — | sets the mode from `{ "mode": "acceptEdits" }`; 400 on an unknown mode, 409 when bypass is locked. |
| POST | `/api/session/{sessionID}/prompt` | `v2.session.prompt` | Admits durable user input (the `session.next.prompted` event) then drives the coordinator to run the turn. The drain runs `Runner::run`, which streams the provider turn and projects the assistant message. |
| GET | `/api/session/{sessionID}/question` | — | — |
| POST | `/api/session/{sessionID}/question/{requestID}/reject` | — | TS validates the path params BEFORE the handler resolves the session, so a malformed `requestID` is a 400 even for a session that does not exist. |
| POST | `/api/session/{sessionID}/question/{requestID}/reply` | — | — |
| POST | `/api/session/{sessionID}/revert/clear` | — | the v2 alias of `Store::unrevert`. |
| POST | `/api/session/{sessionID}/revert/commit` | — | The ported store has no durable "committed" state distinct from "staged" (unlike upstream's stage/commit split, which also touches the working tree), so commit is a no-op success once a bookmark is confirmed to exist — staging already persisted the revert. |
| POST | `/api/session/{sessionID}/revert/stage` | — | Faithful to the v1 revert semantics (`Store::revert` stages a bookmark at `messageID`); `files` (a diff/file-list request) is accepted but the ported `RevertState` carries no diff/files projection, so those fields are omitted rather than fabricated. |
| POST | `/api/session/{sessionID}/wait` | `v2.session.wait` | TS's `V2Session.wait` is a stub: after confirming the session exists it unconditionally fails with `Session.OperationUnavailableError`, mapped to a 503 `ServiceUnavailableError`. Match the status and message exactly so a client cannot tell the servers apart — the existence check still produces 404 for an unknown id first. |
| POST | `/api/session/{sessionID}/subagent/{childID}/message` | — | Steers (`delivery:"steer"`) or queues (`"queue"`, the default) `text` into the child's live turn, or resumes it when it has settled. Returns the delivery status. A missing child is a 404. |
| POST | `/api/session/{sessionID}/subagent/{childID}/resume` | — | Re-enters a completed child with `text` on a fresh step budget and returns its new result. A missing child is a 404. |
| GET | `/api/skill` | `v2.skill.list` | — |

## v1 — auth / catalog / config

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/agent` | `app.agents` | the v1 shape. |
| PUT | `/auth/{providerID}` | `auth.set` | persist a credential to `auth.json`. |
| DELETE | `/auth/{providerID}` | `auth.remove` | drop the stored credential. |
| GET | `/command` | `command.list` | the v1 shape. |
| GET | `/config` | `config.get` | the merged config. |
| PATCH | `/config` | `config.update` | Deep-merges the body over the project config document and persists it. The merged value is written back into `Services` so the cached read path (`config.get`, `formatter.status`, agent/command discovery) reflects the change. TS `ConfigHttpApi.update` returns `ctx.payload` — the SUBMITTED patch, not the merged config — so the request body is echoed. |
| GET | `/config/providers` | `config.providers` | TS `config.providers` returns `providerSvc.list()` — the RESOLVED, connected providers (those with a usable credential via `auth.json`, a stored DB credential, or a declared env key) — plus a per-provider default-model map. It is NOT the bare `oceancode.json` `provider` override block: the TUI's connect gate iterates this list, and an override-only answer left the gate permanently false. |
| GET | `/event` | `event.subscribe` | the v1 SSE stream (`{id,type,properties}` frames). Mirrors `EventSubscribe`. |

## experimental

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/experimental/capabilities` | `experimental.capabilities.get` | Mirrors `experimental.ts capabilities()`: `{backgroundSubagents: …}`: on unless `OC_EXPERIMENTAL_BACKGROUND_SUBAGENTS` is `0`/`false` — the same rule the task tool's background mode reads. |
| GET | `/experimental/console` | `experimental.console.get` | Mirrors `experimental.ts getConsole()`: `{consoleManagedProviders, activeOrgName?, switchableOrgCount}`. With no cloud accounts registered `account.orgsByAccount()` returns `[]` (`account.ts:329` over an empty `repo.list()`), so `switchableOrgCount` is 0; `consoleManagedProviders` is `[]` (`config.ts:589`); `activeOrgName` is omitted. No 500 — the empty result is a success. |
| GET | `/experimental/console/orgs` | `experimental.console.listOrgs` | Mirrors `experimental.ts listConsoleOrgs()`: `{orgs: [...]}`. With no accounts, `orgsByAccount()` is `[]` so the flattened list is empty (`experimental.ts:56`). |
| POST | `/experimental/console/switch` | `experimental.console.switchOrg` | Mirrors `experimental.ts switchConsole()`: `account.use(accountID, orgID)` wrapped in a catch that maps any failure to a 400 `BadRequest` (`experimental.ts:84`). With no account registered `use()` fails, so this is the faithful 400. |
| POST | `/experimental/control-plane/move-session` | `experimental.controlPlane.moveSession` | Mirrors `control-plane.ts moveSession()`: 204 on success; failures map to `MoveSessionError`. `MoveSession` first loads the session (`move-session.ts:77`) — a missing one yields `"Session not found: {id}"` (`control-plane.ts:33`). The move itself is a local operation and is a no-op success when the destination is the session's current directory (`move-session.ts:79`). |
| POST | `/experimental/project/{projectID}/copy` | `v2.projectCopy.create` | — |
| DELETE | `/experimental/project/{projectID}/copy` | `v2.projectCopy.remove` | The contract declares 204. |
| POST | `/experimental/project/{projectID}/copy/generate-name` | `experimental.projectCopy.generateName` | The LLM naming path is not ported; a deterministic slug is returned, matching the `{name}` shape. |
| POST | `/experimental/project/{projectID}/copy/refresh` | `v2.projectCopy.refresh` | The contract declares 204. |
| GET | `/experimental/resource` | `experimental.resource.list` | Mirrors `experimental.ts resource()`: `mcp.resources() -> Record<id, Resource>`. The MCP manager bridges tools but does not enumerate resources, so a fresh instance returns the empty map (`experimental.ts:169`). |
| GET | `/experimental/session` | `experimental.session.list` | Mirrors `experimental.ts session()`: `sessions.listGlobal(...) -> GlobalSession[]`. The global cross-project aggregate (slug/summary/cost/tokens/project) is not ported; a fresh self-hosted instance's `listGlobal` yields `[]` (`experimental.ts:134`). |
| POST | `/experimental/session/{sessionID}/background` | `experimental.session.background` | Mirrors `experimental.ts sessionBackground()`: returns `false` immediately when the `experimentalBackgroundSubagents` runtime flag is off (`experimental.ts:156`). That flag is off by default. |
| GET | `/experimental/tool` | `tool.list` | every registered tool's id/description/parameters. |
| GET | `/experimental/tool/ids` | `tool.ids` | — |
| GET | `/experimental/workspace` | `experimental.workspace.list` | — |
| POST | `/experimental/workspace` | `experimental.workspace.create` | — |
| GET | `/experimental/workspace/adapter` | `experimental.workspace.adapter.list` | Mirrors `workspace.ts adapters() -> listAdapters(projectID)` (`workspace.ts:16`). The only built-in is the worktree adapter (`control-plane/adapters/index.ts BUILTIN`), reported with its name/description (`adapters/worktree.ts:29-30`). |
| GET | `/experimental/workspace/status` | `experimental.workspace.status` | Mirrors `workspace.ts status()`: `[...connections.values()]` filtered to the project's workspaces (`workspace.ts:55, 833`). With no active sync connections the map is empty. |
| POST | `/experimental/workspace/sync-list` | `experimental.workspace.syncList` | Mirrors `workspace.ts syncList()`: discover workspaces via the registered adapters and register any new ones, no body, 204 (`workspace.ts:51, 743`). The built-in worktree adapter discovers nothing without configured remotes, so this is a no-op 204. |
| POST | `/experimental/workspace/warp` | `experimental.workspace.warp` | Mirrors `workspace.ts warp()`: `workspace.sessionWarp(...)` where a missing workspace maps to a 404 `NotFoundError` (`workspace.ts:81`). With no synced workspaces the target is always absent. |
| DELETE | `/experimental/workspace/{id}` | `experimental.workspace.remove` | — |
| GET | `/experimental/worktree` | `worktree.list` | the copy worktree directories. |
| POST | `/experimental/worktree` | `worktree.create` | a linked worktree on a new branch under the repo of the request directory. |
| DELETE | `/experimental/worktree` | `worktree.remove` | INTENTIONAL, SAFER DIVERGENCE FROM TS, carried verbatim from Go: `git worktree remove` refuses to remove the repository's MAIN worktree and that refusal surfaces as a 400. The TS handler is permissive and reports 200 for the same request because its remove path does not guard the main worktree. The guard is kept — matching TS would mean performing (or claiming) a destructive removal of the primary checkout. The non-main path is identical (200) on all three servers. |
| POST | `/experimental/worktree/reset` | `worktree.reset` | hard-reset a linked worktree (the body's directory) back to the default branch of the repo identified by the request directory. |

## files / search

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/file` | `file.list` | direct children as v1 `FileNode`s. |
| GET | `/file/content` | `file.read` | The TS v1 `/file/content` returns only `{type, content}` (no mimeType) and trims the decoded text; both are mirrored so the envelope matches byte-for-byte. |
| GET | `/file/status` | `file.status` | the working-tree git status mapped onto the v1 `File` shape. |
| GET | `/find` | `find.text` | ripgrep-style file-content search rooted at the request directory. `pattern` is the regexp; the results are the ripgrep `match` records the TS handler forwards verbatim. |
| GET | `/find/file` | `find.files` | ranked file-name search returning the matching paths. |
| GET | `/find/symbol` | `find.symbols` | The LSP client exposes hover/definition/diagnostics but not `workspace/symbol`, and no live client is retained, so symbol search has no backend; the endpoint returns an empty-but-valid array. |
| GET | `/formatter` | `formatter.status` | the per-formatter enabled/extensions status computed from config. |

## global / instance

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/global/config` | `global.config.get` | — |
| PATCH | `/global/config` | `global.config.update` | TS `GlobalHttpApi.configUpdate` persists and returns only the submitted patch projection (an empty patch yields `{}` after provider/default stripping), not the full merged config. Echo the request body. |
| POST | `/global/dispose` | `global.dispose` | Mirrors `global.ts dispose()`: returns `true` after disposing. The server is a single long-lived process, so there is nothing to tear down per request (the OS process owns the lifecycle). |
| GET | `/global/event` | — | The v2 `GlobalEvent` stream shared by `GET /api/event` (`v2.event.subscribe`) and `GET /global/event` (`global.event`). |
| GET | `/global/health` | `global.health` | Mirrors `global.ts health()`: `{healthy: true, version}`, plus the four fields this port adds so every running instance says what it is: `model_format` is always `"markdown"` — the only payload format the product renders since D9; the field stays because clients read it, but it is no longer configurable — `ledger` says whether the graph's Session Ledger hook is installed (`ocean_app::Services::ledger`) — leaf T48b / T52b — `build` is the binary's own build stamp (`crate::server_build`, leaf T65), and `dropped_events` is the bus's drop-oldest counter (`ocean_events::Bus::dropped_events`, long-runs plan Task 13 / L-12): live events evicted from a saturated subscriber queue since boot — a saturation signal, not an error count, and never reset. |
| POST | `/global/upgrade` | `global.upgrade` | Mirrors `global.ts upgrade()`: when the installation method is `"unknown"` it reports `{success:false, error:"Unknown installation method"}`. This build is not produced by a known installer. The contract models this as the failure variant of the 200 `anyOf` (the 400 is reserved for the BadRequest envelope), so it is returned with status 200. |
| POST | `/instance/dispose` | `instance.dispose` | Mirrors `instance.ts dispose()`: returns `true`. Single-process, so no per-request teardown. |
| POST | `/log` | `app.log` | write the structured log line then return true. |

## lsp / mcp

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/lsp` | `lsp.status` | The registry resolves candidate servers but does not hold live clients (spawn is on demand), so there is no connected server to report; the endpoint returns an empty-but-valid array. |
| GET | `/mcp` | `mcp.status` | a map of server name -> status. |
| POST | `/mcp` | `mcp.add` | register a server at runtime and connect it, returning the full status snapshot. |
| POST | `/mcp/{name}/auth` | `mcp.auth.start` | begin the remote OAuth flow, returning the authorization URL the caller opens. |
| DELETE | `/mcp/{name}/auth` | `mcp.auth.remove` | The 200 body is `{"success": true}` (`additionalProperties:false`), not a bare boolean. |
| POST | `/mcp/{name}/auth/authenticate` | `mcp.auth.authenticate` | — |
| POST | `/mcp/{name}/auth/callback` | `mcp.auth.callback` | completes the remote OAuth flow with the code returned to the loopback callback and answers with the server's resulting status. |
| POST | `/mcp/{name}/connect` | `mcp.connect` | — |
| POST | `/mcp/{name}/disconnect` | `mcp.disconnect` | — |
| GET | `/mcp/{name}/prompts` | — | the server's cached prompt list (P1-T38). |
| GET | `/mcp/{name}/resources` | — | the server's advertised resources (P1-T38), via `McpManager::list_resources` → `Client::list_resources`. |
| GET | `/mcp/{name}/tools` | — | the server's cached tool definitions (WP6d). |
| POST | `/mcp/{name}/trust` | `mcp.trust` | (GAP-06): persist the per-server trust decision to `<home>/.oceancode/trust.json` (keyed by the project dir) and apply it to the running manager, returning the refreshed `GET /mcp` map. |
| POST | `/mcp/{name}/enable` | `mcp.enable` | (GAP-06 parity): persist the enable/disable decision to `<home>/.oceancode/trust.json`'s `disabled_mcp` (so the next boot honours it) and apply it live to the running manager, returning the refreshed `GET /mcp` map. An unknown server id is a 404. |

## hooks (GAP-06)

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/hooks` | — | the boot-time hook source report plus this project's repo trust bit (GAP-06). Read-only: the rows are whatever the boot loader (or the last reload) recorded. |
| POST | `/hooks` | — | add a command hook to one of Claude Code's three writable settings files (GAP-06 parity), then reload the dispatcher and return the refreshed `GET /hooks` shape. An unknown event / empty command is a 400 (`InvalidInput`); a settings file that is not a JSON object is a 400 (`InvalidData`, never clobbered). The body is an `ocean_app::hooks_boot::HookEdit`. |
| POST | `/hooks/reload` | — | re-run the boot hook loader (re-attaching the dispatcher) and return the refreshed report (GAP-06). |
| POST | `/hooks/remove` | — | remove a hook from one of the three writable settings files (GAP-06 parity), then reload and return the refreshed `GET /hooks` shape. A path that is not one of the three writable scopes is a 400 (`InvalidInput`); a handler that is not present is a 404 (`NotFound`). The body is an `ocean_app::hooks_boot::HookRemove`. |
| POST | `/trust/repo` | — | flip this project's repo-tier trust (GAP-06): persist the decision, release/hold the repo's `.mcp.json` servers and reload the hook dispatcher, then return the refreshed `GET /hooks` shape. A trust-store write error is a 400. |

## the Claude Code settings mirror (2026-09-10)

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/claude/settings` | — | the Claude Code settings mirror (`model`, `effortLevel`/`modelSettings`, `ultracode`, `alwaysThinkingEnabled` of the three tiered `settings.json` files) with the effective effort and the model it pins (`ocean_app::claude_settings`, 2026-09-10). Read-only: the rows are whatever boot or the last watcher reload recorded. |

## path / permission

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/path` | `path.get` | The home/state/config values mirror the TS server's `Global.Path.*`, derived via the xdg-basedir port in `ocean-config` so they are byte-identical to the TS server on the same host/env (e.g. `~/.config/oceancode`, not the macOS `os.UserConfigDir()` location). |
| GET | `/permission` | — | the v1 alias, a bare array. |
| POST | `/permission/{requestID}/reply` | — | the v1 alias, dispatched on `requestID` alone. |

## plugin

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/plugin` | — | the installed plugins with their enable/trust bits. |
| POST | `/plugin/install` | — | install from `source`, returning `{name, version, enabled, trusted}`. |
| GET | `/plugin/marketplace` | — | the added marketplaces with their plugin entries. |
| POST | `/plugin/marketplace/add` | — | add a marketplace by spec. |
| DELETE | `/plugin/marketplace/{name}` | — | remove a marketplace, returning the refreshed marketplace list (GAP-06 parity). A marketplace that is not present is a 404. |
| POST | `/plugin/{name}/enable` | — | turn a plugin's loading on or off. |
| POST | `/plugin/{name}/trust` | — | grant or revoke a plugin's execution trust. A missing/invalid body is a 400 (`falsify_trust_route_requires_body`), so trust is never granted by an empty request. |
| GET | `/plugin/{name}` | — | the full detail of one installed plugin (GAP-06 parity). A name that is not installed is a 404. |
| DELETE | `/plugin/{name}` | — | uninstall a plugin, returning the refreshed installed list (GAP-06 parity). A plugin that is not installed is a 404. |

## project

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/project` | `project.list` | — |
| GET | `/project/current` | `project.current` | resolve the request directory. |
| POST | `/project/git/init` | `project.initGit` | initialize a repository in the request directory (idempotent). |
| PATCH | `/project/{projectID}` | `project.update` | — |
| GET | `/project/{projectID}/directories` | `project.directories` | — |

## provider

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/provider` | `provider.list` | `{all, default, connected}`. |
| GET | `/provider/auth` | `provider.auth` | Mirrors `provider.ts auth()`: `svc.methods() -> Record<providerID, Method[]>`. No v1 auth-method plugins are ported, so this is the empty map a fresh instance returns. |
| POST | `/provider/{providerID}/oauth/authorize` | `provider.oauth.authorize` | Mirrors `provider.ts authorize()`: with no oauth method registered for the provider the TS service hits an undefined method lookup, which the handler maps to a generic `BadRequest` `ProviderAuthError`. The Claude Pro/Max subscription login is served elsewhere (the v2 integration endpoints and the `oceancode login` CLI), so a v1 client is never offered a method here. |
| POST | `/provider/{providerID}/oauth/callback` | `provider.oauth.callback` | Mirrors `provider.ts callback()`: with no pending oauth result the TS service returns `OauthMissing`. |

## pty

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/pty` | `pty.list` | — |
| POST | `/pty` | `pty.create` | — |
| GET | `/pty/shells` | `pty.shells` | the available login shells. |
| GET | `/pty/{ptyID}` | `pty.get` | — |
| PUT | `/pty/{ptyID}` | `pty.update` | — |
| DELETE | `/pty/{ptyID}` | `pty.remove` | The v1 contract answers 200 `true` (unlike the v2 delete, which is 204). |
| GET | `/pty/{ptyID}/connect` | `pty.connect` | (WebSocket upgrade). |
| POST | `/pty/{ptyID}/connect-token` | `pty.connectToken` | — |

## question

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/question` | — | every pending question across sessions. |
| POST | `/question/{requestID}/reject` | — | dismisses a pending question, failing the waiting `Ask` with a rejection. |
| POST | `/question/{requestID}/reply` | — | resolves a pending question with the user's answers, unblocking the waiting `Ask`. |

## session (v1)

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/session` | `session.list` | The v1 response is the bare array. |
| POST | `/session` | `session.create` | Maps the supported v1 body fields onto `SessionStore::create`; `parentID`, `metadata` and `permission` have no ported column and are dropped. |
| GET | `/session/status` | — | the run status of every session, keyed by id. |
| GET | `/session/{sessionID}` | `session.get` | — |
| DELETE | `/session/{sessionID}` | `session.delete` | Removes the session, its children, message projection and event log. |
| PATCH | `/session/{sessionID}` | `session.update` | the v1 title patch. |
| POST | `/session/{sessionID}/abort` | `session.abort` | The TS handler is idempotent: it interrupts any running turn and returns `true` even for an unknown session id (no 404 pre-check). Mirrored. The interrupt goes through the runner, not the bare coordinator, so it also reaches the session's sub-agents — foreground through the token cascade, background through the runner's job index (long-runs plan Task 12, L-9). |
| GET | `/session/{sessionID}/children` | — | the sub-sessions whose `parentID` is this session. The store has no parent filter, so this lists and filters in-process (the TS handler likewise scans). |
| POST | `/session/{sessionID}/command` | — | resolves a registered command, substitutes its arguments, and runs it as a prompt turn. An unknown command maps to `CommandNotFoundError` (404). |
| GET | `/session/{sessionID}/diff` | `session.diff` | the structured diff for the session's working directory (git mode). |
| POST | `/session/{sessionID}/fork` | — | copies the session into a new one (optionally truncated at `messageID`). |
| POST | `/session/{sessionID}/init` | — | runs the project-analysis (`/init`) flow as a prompt turn. |
| GET | `/session/{sessionID}/checkpoints` | — | the recorded checkpoints (id, reason, message bookmark, seq), oldest first. An unknown session lists nothing. |
| POST | `/session/{sessionID}/undo` | — | restore the last pre-write state. With no earlier checkpoint it is a no-op with a `nothing to undo` body, never an error. |
| POST | `/session/{sessionID}/redo` | — | re-apply the next checkpoint's files. Available only after an undo; otherwise a no-op with a `nothing to redo` body. |
| GET | `/session/{sessionID}/checkpoints/{a}/diff/{b}` | — | a unified diff between two checkpoints (text/plain); the header names both short ids. An unknown session or checkpoint is a 404. |
| POST | `/session/{sessionID}/checkpoints/revert-hunk` | — | restore one diff hunk of `file` to its pre-edit (checkpoint) bytes, leaving every other hunk. An unknown hunk id is a 404 (never a whole-file overwrite). |
| POST | `/session/{sessionID}/checkpoints/apply-hunk` | — | re-apply one previously reverted hunk of `file`. |
| GET | `/session/{sessionID}/message` | `session.messages` | the v1 alias. |
| POST | `/session/{sessionID}/message` | `session.prompt` | the v1 prompt alias. |
| GET | `/session/{sessionID}/message/{messageID}` | `session.message` | — |
| DELETE | `/session/{sessionID}/message/{messageID}` | `session.deleteMessage` | — |
| DELETE | `/session/{sessionID}/message/{messageID}/part/{partID}` | `part.delete` | — |
| PATCH | `/session/{sessionID}/message/{messageID}/part/{partID}` | `part.update` | The body is a v1 `Part` (the full tagged-union shape). The flat ported model stores assistant content (text/reasoning/tool) inline rather than as addressable Part rows, so for those types the update is applied to the inline content; for every type the updated Part is returned with the path-derived ids, matching the v1 contract. |
| POST | `/session/{sessionID}/permissions/{permissionID}` | — | the v1 session-scoped response. The `{response}` enum maps directly onto `permission::Reply`. |
| POST | `/session/{sessionID}/prompt_async` | — | admits durably and returns immediately; the turn runs in the background. |
| POST | `/session/{sessionID}/revert` | — | records a revert bookmark at the given message/part. |
| POST | `/session/{sessionID}/share` | — | mints (or returns) the share row. |
| DELETE | `/session/{sessionID}/share` | — | drops it. |
| POST | `/session/{sessionID}/shell` | — | runs a shell command in the session's working directory, recording it as a durable shell message. |
| GET | `/session/{sessionID}/subagent` | — | the session's effective sub-agent model (2026-09-16), so a remote TUI shows the identity row's `sub-agents` segment. The engine owns the whole precedence (`ocean_app::Services::effective_subagent_model`); this hands its answer back as `{"model": ModelRef \| null}`. An unknown session is 404, like the sibling per-session GETs. |
| POST | `/session/{sessionID}/summarize` | — | runs a summarization turn and reports whether a summary was produced. |
| GET | `/session/{sessionID}/todo` | `session.todo` | The `ETag` header is the table's entity tag; send it back as `If-Match` on `PUT` for a conditional edit. |
| PUT | `/session/{sessionID}/todo` | — | the user's edit. Rows are matched by id or content; changed and new rows become user-authored; rows left out are removed. With `If-Match` set to a stale tag nothing is written and the answer is `412` carrying the current rows and tag. |
| POST | `/session/{sessionID}/unrevert` | — | clears the revert bookmark. |
| GET | `/skill` | `app.skills` | the v1 bare array. |

## sync

| Method | Path | Operation | What it does |
|---|---|---|---|
| POST | `/sync/history` | `sync.history.list` | Mirrors `sync.ts history()`: select every durable event from the `event` table, excluding the `(aggregateID, seq)` cursors in the payload, ordered by seq (`sync.ts:72`). The payload maps `aggregateID -> lastSeq` the caller already has. |
| POST | `/sync/replay` | `sync.replay` | Mirrors `sync.ts replay()`: replay the serialized events into the event store, return `{sessionID: events[0].aggregateID}` (`sync.ts:34`). TS's `ReplayPayload` declares `directory: Schema.String` and `events: Schema.NonEmptyArray(ReplayEvent)`, so the HttpApi rejects a missing directory or an empty array with a 400 BEFORE the handler runs. |
| POST | `/sync/start` | `sync.start` | Mirrors `sync.ts start()`: fork `startWorkspaceSyncing` with `Effect.ignore`, return `true` (`sync.ts:27`). The fork is best-effort and its result is ignored, so the response is unconditionally `true`. |
| POST | `/sync/steal` | `sync.steal` | Mirrors `sync.ts steal()`: requires the instance `workspaceID`; without one it returns 400 `BadRequest` (`sync.ts:62`). A self-hosted instance has no workspace binding. The contract's 400 is the effect `HttpApiError BadRequest` (`{_tag:"BadRequest"}`), not the `{name,data}` envelope. |

## tui

| Method | Path | Operation | What it does |
|---|---|---|---|
| POST | `/tui/append-prompt` | `tui.appendPrompt` | — |
| POST | `/tui/clear-prompt` | `tui.clearPrompt` | — |
| GET | `/tui/control/next` | `tui.control.next` | long-poll for the next queued TUI request (FIFO). Parks until a request is enqueued or the client disconnects. |
| POST | `/tui/control/response` | `tui.control.response` | enqueue the TUI's reply for whoever is waiting (fire-and-forget FIFO). |
| POST | `/tui/execute-command` | `tui.executeCommand` | — |
| POST | `/tui/open-help` | `tui.openHelp` | — |
| POST | `/tui/open-models` | `tui.openModels` | — |
| POST | `/tui/open-sessions` | `tui.openSessions` | — |
| POST | `/tui/open-themes` | `tui.openThemes` | — |
| POST | `/tui/publish` | `tui.publish` | dispatch a TUI event onto the request queue for a connected client to pull via `control/next`. |
| POST | `/tui/select-session` | `tui.selectSession` | — |
| POST | `/tui/show-toast` | `tui.showToast` | — |
| POST | `/tui/submit-prompt` | `tui.submitPrompt` | — |

## vcs

| Method | Path | Operation | What it does |
|---|---|---|---|
| GET | `/vcs` | `vcs.get` | current/default branch info. |
| POST | `/vcs/apply` | `vcs.apply` | applies a unified patch to the working tree. The contract's 200 body is `{applied: boolean}`. |
| GET | `/vcs/diff` | `vcs.diff` | the structured per-file diff. |
| GET | `/vcs/diff/raw` | `vcs.diff.raw` | The contract's 200 body is a `text/x-diff` string (not JSON), so the diff is written verbatim with that content type. |
| GET | `/vcs/status` | `vcs.status` | the working-tree file status list. |

Generated from `crates/ocean-server/src/router.rs` — 218 operations.
