REST API reference

The workflow, run, config, and docs HTTP endpoints, with request and response shapes.

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). 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 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.

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):

{
  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:

{
  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 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.
  • 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 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 " 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 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.

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 — the other programmatic surface, which drives runs through an agent rather than over HTTP.
  • Web UI — the client these endpoints exist to serve.
  • Environment variablesROSCOE_HOME, ROSCOE_PORT and the rest of what decides where the server listens and which database it opens.
  • Database recovery — what a user actually does when the degraded mode above kicks in.

View this page as Markdown

Predictable workflows from unpredictable AI