# REST API reference

The endpoints a UI or script drives directly: workflow CRUD, runs, config,
and docs. `apps/server/src/routes/*.ts` defines the routes;
`apps/server/src/app.ts` mounts them. All responses are JSON unless
otherwise noted.

Not covered here: `/api/version` (update-state plumbing for the app shell's
Settings card and sidebar badge; it is reachable in degraded mode, which is
why it appears in the allow-list below), `/api/session` and `/api/serve/*`
(local auth-token and update-control plumbing the CLI/desktop shell use, not
a stable public surface), `/api/projects*` and `/api/mcp/*`
(project-registry and onboarding-probe internals), `/api/cli-path*`,
`/api/onboarding*` and `/api/analytics*` (first-run setup and
crash-reporting plumbing), and the `/api/*-events` SSE streams
(`config-events`, `workflow-events`, `projects-events`) that back the web
app's live-reload.

## Hosts and ports

- Dev (`bun run dev`): `127.0.0.1:8080`, from `ROSCOE_API_PORT` in
  `.env.dev`.
- `roscoe serve`: `127.0.0.1:7777`, from `--port`, then `ROSCOE_PORT`, then
  `roscoe.yaml` `ports.serve`, then the fallback.

The dev server runs on a separate origin from the web UI (`:3000`); the
compiled binary serves both API and web on the same origin (no CORS
needed).

## Conventions

- All endpoints are under `/api`, except `GET /open/runs/:id` (see
  [Open](#open)). Anything else served by `roscoe serve` is the embedded web
  UI (single-page-app fallback to `index.html` for non-`/api` paths).
- Status codes follow Hono RPC conventions: explicit numeric literals,
  never inferred. Validation failures return `400`, missing entities `404`,
  conflicts `409`, server errors `500`. The DB-unavailable degraded mode
  returns `503` for most API routes; see [Degraded
  mode](#degraded-mode-db-unavailable) for the two rules that decide what
  stays live.
- DTO shapes are defined in `@roscoe/schema`. The web client derives types
  from `AppType` (exported from `apps/server/src/app.ts`).

## Index

| Group          | Endpoints                                                                                                                                                                                                                                                                                                                                                                                                                       |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Health / admin | `GET /api/health`, `POST /api/admin/db-reset`                                                                                                                                                                                                                                                                                                                                                                                   |
| Init           | `GET /api/init/status`, `POST /api/init/seed-examples`, `POST /api/init/recheck`                                                                                                                                                                                                                                                                                                                                                |
| Runtime        | `GET /api/runtime`                                                                                                                                                                                                                                                                                                                                                                                                              |
| Workflows      | `GET /api/workflows`, `GET /api/workflows/references`, `POST /api/workflows`, `GET /api/workflows/:id`, `PUT /api/workflows/:id`, `DELETE /api/workflows/:id`, `POST /api/workflows/:id/move`                                                                                                                                                                                                                                   |
| Runs           | `POST /api/runs`, `GET /api/runs`, `GET /api/runs/branches`, `GET /api/runs/workflows`, `GET /api/runs/:id`, `GET /api/runs/:id/detail`, `GET /api/runs/:id/nodes`, `GET /api/runs/:id/nodes/children`, `GET /api/runs/:id/awaiting-chain`, `GET /api/runs/:id/tree`, `GET /api/runs/:id/nodes/:nodeRunId/children`, `GET /api/runs/:id/workflow`, `POST /api/runs/:id/resume`, `POST /api/runs/:id/cancel`, `DELETE /api/runs` |
| Recordings     | `GET /api/recordings/renderer`, `POST /api/recordings/renderer/install`, `GET /api/recordings/:runId`, `PUT /api/recordings/:runId/storyboard`, `POST /api/recordings/:runId/export`, `GET /api/recordings/:runId/export`, `POST /api/recordings/:runId/export/cancel`, `POST /api/recordings/:runId/export/open`                                                                                                               |
| Config         | `GET /api/config`, `GET /api/config/settings`, `PUT /api/config`, `POST /api/config/reload`                                                                                                                                                                                                                                                                                                                                     |
| Docs           | `GET /api/docs/manifest`, `GET /api/docs/page/:splat`, `GET /api/docs/search`                                                                                                                                                                                                                                                                                                                                                   |
| Open           | `GET /open/runs/:id`                                                                                                                                                                                                                                                                                                                                                                                                            |

## Health and admin

### `GET /api/health`

Reports DB status. Always 200 even when the DB is corrupted: most of the API
serves 503 in that case, but this endpoint and a short allow-list stay
reachable. Every non-API `GET`/`HEAD` passes too, so the app shell loads and
the recovery UI can render. See
[Degraded mode](#degraded-mode-db-unavailable).

**Response:** the `DbStatus` union (`{ status: 'ok' }` or `{ status:
'unavailable', reason, dbPath }`) plus `roscoeHome` (string) and `version`
(string), always present, and `webPort` (number), present when
`ROSCOE_WEB_PORT` or `roscoe.yaml` `ports.web` resolves to a positive
integer.

### `POST /api/admin/db-reset`

Archive the current database file and create a fresh one. Used by the
in-app recovery screen. The corrupted file is preserved at
`~/.roscoe/roscoe.db.corrupt-<timestamp>` for forensics.

**Body:** `{ "confirm": "reset" }` (literal — anything else is a 400).

**Response:** `{ ok: true, archivedPath }` (200) or
`{ ok: false, archivedPath, reason }` (500) if the post-reset DB still
fails to open.

## Init

### `GET /api/init/status`

Probe local environment for the onboarding card.

**Response:** `InitStatus`: `{ claudeCli, hasApiKey, cliOnPath,
cliInstalledAtCanonical, workflowCount, hasRepo, repoPath: string | null,
repoIsGitRoot, repoIsInitialized }`. `repoPath` is
the active project's path (null when there's no active project);
`repoIsGitRoot` and `repoIsInitialized` describe that path's git and
`.roscoe/` state.

### `POST /api/init/seed-examples`

Drop the bundled example workflows into the global workflows dir.

**Body:** `{ force?: boolean }` (omit for default `force: false`).

**Response:** `{ created: string[], skipped: string[] }` or `{ error }` (500).

### `POST /api/init/recheck`

Drop the memoized `claude` CLI probe and run it again. This backs the
onboarding card's "Re-check" button; the probe is otherwise memoized for 60
seconds.

**Response:** `InitStatus`, freshly probed.

## Runtime

### `GET /api/runtime`

Tells the web client whether it's running under `bun run dev` or
`roscoe serve`, and whether a project source is detected.

**Response:** `{ mode: 'dev' | 'serve', hasProject: boolean, activeProjectId:
string | null, activeProjectName: string | null }`. The last two are null
when no project is active (Global workspace).

## Workflows

### `GET /api/workflows`

List all workflows (project + global) with metadata.

**Response:** `WorkflowSummary[]`: each item includes `id`, `name`,
`description`, `valid`, `errors`, `source` (`'global' | 'repo'`), `shadowed`,
`lastRunAt`, `lastRunStatus`, `lastRunPauseKind`.

### `GET /api/workflows/references`

The full `subworkflow` reference graph: every edge across all workflow
files, not scoped to one target id. Feeds the editor's child-workflow
picker so it can grey out choices that would close a cycle.

**Response:** `WorkflowReferenceEdge[]`: each edge is
`{ from, to, nodeId }` (`from` references `to` through its `nodeId` state).

### `POST /api/workflows`

Create a new workflow with the minimal template.

**Body:** `{ id: string (matches /^[a-zA-Z0-9_-]+$/, max 128 chars), source: 'global' | 'repo' }`.

**Response:** `{ id, source }` (201) or `{ error }` (400/404/409/500).

### `GET /api/workflows/:id`

Fetch a workflow's parsed config plus validity report.

**Query:** `source?: 'global' | 'repo'` (optional, repo-first by default).

**Response (200):**

```ts
{
  config: WorkflowConfig | null,
  source: 'global' | 'repo',
  valid: boolean,
  errors: string[]
}
```

`config` is null when the file fails to parse or schema-validate; `errors`
explains why.

### `PUT /api/workflows/:id`

Replace a workflow's contents. Validates schema + integrity before writing.

**Query:** `source?: 'global' | 'repo'`.

**Body:** the full `WorkflowConfig` (Zod-validated).

**Response:** `{ ok: true, source }` (200) or `{ error }` (400/404/409/500).

### `DELETE /api/workflows/:id`

Hard-delete the workflow file.

**Query:** `source?: 'global' | 'repo'`.

**Response:** `{ ok: true, source }` (200) or `{ error }` (400/404/500).

### `POST /api/workflows/:id/move`

Move a workflow between Global and Project. Refuses if the target source
already has the same id.

**Body:** `{ to: 'global' | 'repo' }`.

**Response:** `{ ok: true, id, from, to }` (200) or `{ error }`
(400/404/409/500).

## Runs

### `POST /api/runs`

Start a workflow run. Provide exactly one of `workflowId` or `workflowPath`.

**Body:**

```ts
{
  workflowId?: string,          // one of workflowId or workflowPath
  workflowPath?: string,        // absolute or CLI-relative *.workflow.yaml path
  context?: Record<string, unknown>,
  source?: 'global' | 'repo',   // not used to resolve a workflowPath, but still recorded as the run's source
  projectId?: string,           // override; defaults to the active project
  cwd?: string,                 // absolute; used to capture git branch/commit
  spending_cap_usd?: number,
  spending_cap_tokens?: number,
}
```

**Response:** `{ runId }` (201), `{ error }` (404 if workflow not found,
400 for client errors, 500 otherwise).

### `GET /api/runs`

List runs, newest first.

**Query:**

| Param             | Type                            | Default    | Behaviour                                                                                      |
| ----------------- | ------------------------------- | ---------- | ---------------------------------------------------------------------------------------------- |
| `limit`           | int                             | 50         | Capped at 200                                                                                  |
| `kind`            | `'real' \| 'test' \| 'all'`     | `'real'`   |                                                                                                |
| `scope`           | `'active' \| 'global' \| 'all'` | `'active'` | `active` = the server's active project; `global` = runs with no project; `all` = every project |
| `branch`          | string                          | —          | Filter to one git branch                                                                       |
| `workflow`        | string                          | —          | Filter to one workflow id                                                                      |
| `includeChildren` | `'true'`                        | omitted    | Interleave each top-level run with its direct sub-workflow children                            |

**Response:** `WorkflowRun[]`. Each paused run carries a derived `pauseKind`
(`'human'` | `'agent'`): `'agent'` marks an MCP (Model Context Protocol)
[handback](/docs/running/subscription#how-the-consensus-handback-works) the
calling agent is actively processing (`ai_agent` / `ai_judge` / `consensus` /
`round_robin` / `map`),
which the UI surfaces as "Processing" rather than "Paused". It's `null` for any
non-paused run. With `includeChildren=true`, child rows carry `parentRunId`
and a `nestedDescendants` count of their own subtree; parent rows leave both
`nestedDescendants: 0` and rely on the listed children instead.

### `GET /api/runs/branches`

Distinct git branch names that have appeared on a top-level run, most
recent first. Same `scope` query semantics as `GET /api/runs`.

**Response:** `string[]`.

### `GET /api/runs/workflows`

Distinct `{ workflowId, workflowName }` pairs that have run, most recent
first. Same `scope` query semantics as `GET /api/runs`. Drives the run
history's workflow filter.

**Response:** `Array<{ workflowId, workflowName }>`.

### `GET /api/runs/:id`

Fetch a single run record.

**Response:** `WorkflowRun` (200) or `{ error: 'Run not found' }` (404).

Two fields are derived rather than stored:

- `pauseKind`, described under [`GET /api/runs`](#get-apiruns).
- `hasStoryboard`, whether this run has been directed in the recording editor.
  The run page reads it to label its Record button, so it is an existence check
  on the storyboard file, not a read of it.

### `GET /api/runs/:id/detail`

The `AdvanceResult` shape (`{ runId, status, pendingStep?, totalCostUsd?,
meteredCostUsd?, subscriptionEquivCostUsd?, inputTokens?, outputTokens?,
billingMode?, spendingCapUsd?, spendingCapTokens?, stopReason? }`): the
same contract the [MCP](/docs/running/mcp) tools and the CLI's interactive
view consume. Use this to poll a run for a pending AI step without going
through MCP.

**Response:** `AdvanceResult` (200) or `{ error }` (404 unknown run, 500
otherwise).

### `GET /api/runs/:id/nodes`

List the per-node run records for a run. A `subworkflow` node row carries
an additional `childRunId` once its child run has started, so the client
can render an "Open <subworkflow>" link before the parent node completes.

**Response:** `NodeRun[]` (200) or `{ error: 'Run not found' }` (404).

### `GET /api/runs/:id/nodes/children`

Every fan-out child row for the run in one query: consensus agents,
round-robin matchups, map items. The canvas polls this while a run is in
flight, rather than issuing one request per parent node.

**Response:** `NodeRun[]` (200) or `{ error: 'Run not found' }` (404).

### `GET /api/runs/:id/awaiting-chain`

Walk from a paused run down to its deepest blocking leaf across nested
`subworkflow` runs, in the shape the pause label / CLI panel / MCP error
messages need.

**Response:** `AwaitingChainHopDto[]` (200), each hop
`{ runId, workflowName, pausedAtNode, awaitingChild }`. Empty array when
the run isn't paused or the walk finds no leaf.

### `GET /api/runs/:id/tree`

Recursively expand a run's full sub-workflow children. Used by the web
app's drill-in view and the CLI's tree renderer.

**Response:** `RunTreeNode` (200): `{ run, nodeRuns, children: RunTreeNode[],
truncated }`, where `truncated` is true only when the descent was cut off
at the max nesting depth. Or `{ error: 'Run not found' }` (404).

### `GET /api/runs/:id/nodes/:nodeRunId/children`

List child sub-runs (e.g. consensus agents) of a parent node run. Verifies
the child belongs to the run before returning.

**Response:** `NodeRun[]` (200) or `{ error: 'Run not found' }` /
`{ error: 'Node run not found' }` (404).

### `GET /api/runs/:id/workflow`

Re-load the workflow config this run executed against. Prefers the
immutable `configSnapshot` captured at run start (so it renders the exact
topology that ran, regardless of later moves/edits/deletes); falls back to
re-resolving the stored path, then the workflow id, for older runs with no
snapshot.

**Response:** `WorkflowConfig` (200), or 404 for an unknown run or for a run
with no stored path and no resolvable id, or 422 if every candidate file is
invalid.

### `POST /api/runs/:id/resume`

Resume a paused run.

**Body:** `{ transition: string }` (the transition key chosen by the user).

**Response:** `{ runId }` (200) or `{ error }` (400 for `RunnerClientError`,
500 otherwise).

### `POST /api/runs/:id/cancel`

Cancel a running or paused run. Roscoe flips the run to `cancelled` durably
and interrupts any in-flight executor work: it aborts the spawned script
worker or `claude` CLI/SDK call so it stops promptly instead of finishing in
the background. It finalizes every node the run was sitting on (including
nested rows such as consensus agent sub-runs) with the dedicated `cancelled`
node status, distinct from `failed`. Cancel also cascades to
descendant runs spawned via subworkflow nodes.

**Response:** `{ runId }` (200) or `{ error }` (404 for an unknown run or one
that's already terminal, 500 otherwise).

### `DELETE /api/runs`

Wipe the entire runs table. Used by the "Clear run history" button in the
web UI; gated by a confirmation modal in the web client only.

**Response:** `{ ok: true }` (200).

## Recordings

A finished run as a recording: the fixture it plays back, the saved
direction, and the export job. Every `:runId` route answers `400` for a
malformed id and `409` for a run that has not finished; the export
status/cancel/open routes act on the in-memory job and check only the id's
shape. See [Record a run](/docs/running/record-a-run) for what the routes
are for.

### `GET /api/recordings/renderer`

Whether this machine can render. A machine property, not a run's.

**Response:** `RendererStatus` (200): `{ kind: 'ready' }`,
`{ kind: 'needs-download', bytes, diskBytes, installDir }`, or
`{ kind: 'unavailable', reason }` where `reason` is one of `linux`,
`platform`, `no-h264`, `home-read-only`, `no-space`.

### `POST /api/recordings/renderer/install`

Download the renderer now, without an export. Idempotent; a second call while
one is in flight joins it.

**Response:** `RendererStatus` (200) once the install has finished or failed.

### `GET /api/recordings/:runId`

**Response:** `RecordingDto` (200): `{ fixture, storyboard, export, renderer }`.
`storyboard` is `null` until the run has been opened in the editor;
`export` is the job's `RecordingExportStatus`; `renderer` is the machine's
`RendererStatus`. `404` for an unknown run.

### `PUT /api/recordings/:runId/storyboard`

Save the direction.

**Body:** a `Storyboard` (validated against `StoryboardSchema`).

**Response:** `{ ok: true }` (200).

### `POST /api/recordings/:runId/export`

Start a render. Must be called from this machine (a loopback `Origin`), and
after a storyboard has been saved.

**Body:** `{ scheme: 'dark' | 'light', durationMs: number, acceptDownload?: boolean }`.
`durationMs` only drives the progress display, and is capped at one hour; a
longer value is rejected. `acceptDownload` lets the export download the
renderer first on a machine that has none (the web app sends it; the first
export sets the renderer up on its own). Without it, such a machine refuses
rather than pulling 99–120 MB, depending on platform.

**Response:** `RecordingExportStatus` (202); `409` with
`{ error, renderer }` when the renderer needs downloading and the body did
not allow it, or when the machine cannot render; `400` for a non-loopback
origin.

### `GET /api/recordings/:runId/export`

**Response:** `RecordingExportStatus` (200): `idle`, `downloading`
(`startedAt`, `received`, `total`), `rendering` (`startedAt`, `stage`,
`frame`, `frames`), `done` (`path`, `bytes`, `durationMs`, `finishedAt`) or
`failed` (`phase`, `cause`, `message`).

### `POST /api/recordings/:runId/export/cancel`

Stop the render or the download. A cancelled download starts over next time;
a completed install is never removed.

**Response:** `RecordingExportStatus` (200).

### `POST /api/recordings/:runId/export/open`

Open the finished file, or reveal it in the file manager.

**Body:** `{ reveal: boolean }` (required: an empty POST must not reach a
launcher).

**Response:** `{ ok: true }` (200); `404` when there is no finished export.

## Config

### `GET /api/config`

Returns the merged `roscoe.yaml` config (project overlays global).

**Response:** `RoscoeConfig` (200) or `{ error }` (500).

### `GET /api/config/settings`

Data for the Settings page: the effective global config, plus the active
project's own `models` / `defaults` / `pricing` overrides (each null when
the repo file doesn't set it) and the names of any other sections the repo
file sets. `project` is null when there's no active project.

**Response:** `SettingsDataDto`: `{ global: RoscoeConfig, project: {
name, models, defaults, pricing, otherOverrides: string[] } | null,
modelDefaults: Record<string, { input: number; output: number }> }`
(200) or `{ error }` (500).

### `PUT /api/config`

Persist a config to project or global. Preserves comments and key order in
the existing file when possible; falls back to a fresh serialise (with the
`# yaml-language-server: $schema=...` header) when the existing content
isn't parseable.

**Query:** `source: 'project' | 'global'` (required: note the `'project'`
spelling here vs. `'repo'` for workflows).

**Body:** `RoscoeConfigFile` (Zod-validated) — the same sections as
`RoscoeConfig`, but every section optional. Only the sections a source is
allowed to manage are reconciled: `global` writes `models`, `ports`,
`defaults`, `preferences`, `analytics`, `pricing`; `project` writes only
`models`, `defaults`, `pricing`. A listed section that's absent or empty is
removed from the file rather than left stale.

**Response:** `{ ok: true, source }` (200) or `{ error }` (400/500).

### `POST /api/config/reload`

Force a re-read of `roscoe.yaml` from disk and return the result. Used by
the file-watcher on the web side to pick up external edits.

**Response:** `RoscoeConfig` (200) or `{ error }` (500).

## Docs

### `GET /api/docs/manifest`

Flat catalogue of all doc pages.

**Response:** `DocsManifest`:
`{ entries: [{ slug, title, description, order? }] }`.

### `GET /api/docs/page/:splat`

Fetch one page. The `:splat` segment captures slashes
(e.g. `recipes/approval-flow`). The `.md` suffix is stripped if supplied.

**Response:** `DocPage`: `{ frontmatter, markdown }`. 400 on invalid slug,
404 on unknown.

### `GET /api/docs/search`

Full-text search across docs.

**Query:** `q: string` (required), `limit?: number` (1..100, default 20).

**Response:** `SearchHit[]` ranked by relevance.

## Open

### `GET /open/runs/:id`

Open a run in the desktop app if it is installed, and in the browser if it is
not. **Not under `/api/`** — this is a navigation target meant to be clicked,
not a JSON endpoint, and it is what the MCP tools emit as a run's `url`.

The server decides by asking the OS whether the `roscoe://` scheme is
registered, then either fires `roscoe://run/<id>` at it or falls back. Nothing
is guessed from bundle paths.

| Outcome                        | Response                                                                                                  |
| ------------------------------ | --------------------------------------------------------------------------------------------------------- |
| App installed, handoff fired   | `200` — a small script-free page saying "Opening in Roscoe…", with a link to open in this browser instead |
| App not installed              | `302` to `/runs/<id>?hint=desktop` — the run page, plus a one-line note that a desktop app exists         |
| Platform has no desktop build  | `302` to `/runs/<id>` — no hint, since there is nothing to download                                       |
| Run id well-formed but unknown | `302` to `/runs/<id>` — the SPA renders its own not-found page                                            |
| Run id malformed               | `404`                                                                                                     |
| DB unavailable                 | `302` to `/runs/<id>` — no app is launched for a run the server cannot vouch for                          |
| `ROSCOE_DISABLE_OPEN=1`        | `302` to `/runs/<id>` — the escape hatch, so link clicks cannot resurrect an app you deliberately quit    |

A `?project=` query is preserved through every branch, so the target
auto-selects the right workspace.

The `200` page carries no inline JavaScript, and deliberately does not
auto-navigate after a delay: doing so would leave a browser tab showing the
same run as the app window, which is the duplication this route exists to
remove.

## Degraded mode (DB unavailable)

When the SQLite file fails to open at boot, `getDbStatus()` returns
`unavailable` and middleware answers most requests with a 503 carrying that
same payload. Two rules decide what still gets through.

**Rule 1: the web UI stays reachable.** Any `GET` or `HEAD` outside `/api/`
passes. These requests fetch the app shell and its assets, and a 503 there
would not degrade the UI, it would remove it: the document never loads, so
`<DbHealthGate>` never mounts, so the recovery screen never renders and the
reset button below is unreachable. Read-only verbs only: a write to a
non-API path is still gated.

**Rule 2: a short API allow-list.** These exact paths stay reachable so the
recovery screen has something to talk to: `/api/health` and
`/api/admin/db-reset` (the recovery flow itself), `/api/session` and
`/api/version` (read-only, needed to render the shell), `/api/serve/restart`
and `/api/serve/install-update` (a broken DB is often fixed by an update or
restart), the filesystem-only `/api/mcp/status`, `/api/cli-path/status` and
`/api/onboarding` and `/api/onboarding/complete` (first-run onboarding may run
before the DB has settled — spelled out rather than globbed, because the match
is exact: a future `/api/onboarding/x` would NOT be admitted),
and `/api/analytics/web-config` (crash reporting is most valuable precisely
when the DB is broken). `ALLOW_WHEN_UNAVAILABLE` in `apps/server/src/app.ts`
is the list, and `reachableWhenDbUnavailable()` beside it applies both rules.
Note the list matches on pathname only, not method, which is why the cli-path
writes live on separate `/add` and `/remove` paths and the MCP `/register`
write is deliberately absent.

The web UI's `<DbHealthGate>` reads `/api/health` once on mount and renders the
recovery screen instead of the normal app; see
[Database recovery](/docs/troubleshooting/database-recovery).

`roscoe serve` calls `initDb()` before it starts listening, so a database
that cannot be opened is reported as `unavailable` rather than surfacing
later as 500s from a server still claiming `ok`.

## Where to next

- [MCP tools](/docs/reference/mcp-tools) — the other programmatic surface, which
  drives runs through an agent rather than over HTTP.
- [Web UI](/docs/running/web-ui) — the client these endpoints exist to serve.
- [Environment variables](/docs/configuration/environment) — `ROSCOE_HOME`,
  `ROSCOE_PORT` and the rest of what decides where the server listens and which
  database it opens.
- [Database recovery](/docs/troubleshooting/database-recovery) — what a user
  actually does when the degraded mode above kicks in.
