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, fromROSCOE_API_PORTin.env.dev. roscoe serve:127.0.0.1:7777, from--port, thenROSCOE_PORT, thenroscoe.yamlports.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, exceptGET /open/runs/:id(see Open). Anything else served byroscoe serveis the embedded web UI (single-page-app fallback toindex.htmlfor non-/apipaths). - Status codes follow Hono RPC conventions: explicit numeric literals,
never inferred. Validation failures return
400, missing entities404, conflicts409, server errors500. The DB-unavailable degraded mode returns503for 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 fromAppType(exported fromapps/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 underGET /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 variables —
ROSCOE_HOME,ROSCOE_PORTand 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.