API reference

rupu cp serve serves a JSON API under /api/*; the web UI is built entirely on it, and so are peer control planes that drive this host remotely. This page lists every route. The operator guide is Control plane; customer scoping, attribution and the launch preview are covered in more depth in Customers.

Request and response bodies are named by their Rust type where they are large; those types live in crates/rupu-cp/src/api/ (and rupu_orchestrator / rupu_transcript for events). The API is versioned with the binary: a feature a peer needs is advertised in GET /api/host/info → features.

For agents rather than apps, rupu cp serve also serves every route here as an MCP tool at /mcp (plus a wait long-poll); see MCP & tools.

Conventions

Authentication

Started without --token, the API is open. Started with --token <t>, every /api/* route requires the token, either as the header

Authorization: Bearer <t>

or as the browser cookie rupu_cp_token_<port>. A page load (any non-/api path) with ?token=<t> sets that cookie and answers 303 to the same URL without the parameter. The cookie's value is derived from the token, not the token, and is not accepted as a bearer. A cookie-authenticated request that isn't GET/HEAD/OPTIONS must also send an Origin matching the Host it was sent to, or it is a 403. Comparisons are constant time. A missing or wrong token is a 401 with the standard {"error": ...} body. ?token= is ignored on /api paths. /healthz, the static UI and /api/node/connect (which authenticates nodes with their own enrollment token) are outside the check. See Control plane → Authentication.

curl -s -H "Authorization: Bearer $CP_TOKEN" \
  "http://127.0.0.1:7878/api/runs?limit=5"

Errors

Handler errors are JSON:

{ "error": "run 01JEXAMPLE0000000000000000 not found" }

A few endpoints add fields (a bad findings query adds token, code, start, end; deleting a customer with projects adds projects; a failed artifact pull is { "unavailable": "<reason>" }). A malformed query string or JSON body is rejected by the framework before the handler runs, with a plain-text 400/415/422. An unknown /api/... path is a JSON 404 (no API route for /api/...), never the web app.

Path parameters (:id, :name, :ws_id, …) are single plain path components. Before any handler runs, a request is a 400 if any /api/* path segment percent-decodes to something that can't be one: ., .. or a name starting with . or -, an encoded / or \, an encoded ? or #, a control character, or an invalid escape. Handlers validate their own parameters on top of that.

StatusMeaning across the API
400Bad input: an id or path that fails validation, a bad slug, a malformed query.
401Missing or wrong token.
403A cookie-authenticated write from another origin; a /api/fs/browse path outside the browsable directories.
404Unknown run / session / finding / host id.
409The request conflicts with current state: approving a run not awaiting approval, deleting a run that isn't finished, resuming a run that isn't paused.
500Internal error: an unreadable store, a fault on this server's own side.
501This server can't do it: an adapter only rupu cp serve installs is missing, a remote host's transport doesn't support the operation, or a remote host can't answer this query (too old).
502A remote host failed: unreachable, unauthorized, or an error or non-JSON reply. The same on lists (shown "offline" in the UI), detail views, streams and controls.

Common query parameters

Remote host errors

One table holds for every ?host=<remote> call:

The host…ListDetail / graph / log / usage / netflow / sessionControl (approve, reject, cancel, pause, resume, archive, restore, delete)
has no such run / session502404404
refuses the transition——409
can't serve it (transport, too old)501501501
is unreachable or answers badly502502502

A list's "not found" is a 502 because a reachable host answering a list route with 404 is broken, not empty. A 404 that names the host (host <id> not found) always means the id isn't registered.

List queries

A q term's values OR, terms AND, - negates, a bare word searches the list's text fields; comparisons are case-insensitive. It filters before offset/limit cut the page and ANDs with the list's other parameters (lifecycle, scope, since, until, customer). Remote hosts' rows are filtered on this server, reading past peer page clamps, as customer is: a host whose rows can't report a field the query names (no customer key: an old peer) is a 501 on ?host=<it>, and left out of a fan-out and named in X-Rupu-Hosts-Without-Customer (the customer field) or X-Rupu-Hosts-Unfilterable (<host>:<field>, any other field). A q that doesn't parse is a 400 {error, token, code, start, end} (token index and char span). Time fields (since, until) take 30m, 12h, 7d, 2w (back from now, at most 10000 years), a UTC date (2026-10-01, the whole day) or an RFC 3339 time. Each list's fields, with their kind, values and match mode, are in crates/rupu-cp/tests/fixtures/list_query/<list>.fields.json, and Query help serves them.

ListFields (match)Bare words search
Workflow runs (/api/runs/workflows, /api/projects/:ws_id/runs)status (one of the 8 run statuses), workflow (contains), id (prefix), codename (contains), trigger (manual/cron/event), host, customer (a slug or none), since, until (started_at), is:archivedworkflow, id, codename
Agent runs (/api/runs/agents)status (exact), agent (contains), provider, model (contains), is (running/completed/failed: the lifecycle group, a finished standalone run with no status is completed), source (standalone/session), session (prefix), trigger (trigger_source), codename, id (prefix), host, customer, since, until (started_at)agent, codename, id
Sessions (/api/sessions, /api/projects/:ws_id/sessions)status (idle/running/failed/stopped), agent (contains), model (contains), provider, codename, id (prefix), host, customer, project (alias workspace; the workspace id), is:active/is:archived (the session's scope), since, until (created_at)agent, codename, id
Events (/api/events)group (activity/await/warning/error; finding is the page's findings, never an event), type (the event type), run (prefix), project, workflow, agent, step (contains), codename (the event's or its run's, contains), provider, model, severity (ordered; events carry none), message (the error, reason, warning or note), since, until (ts)run, project, workflow, agent, step, codename, message

is:archived on /api/runs/workflows lists the archive instead (rows carry archived: true); it is local only: a remote host is a 501, and on a fan-out each remote host is named <host>:is. On /api/sessions it reads each session's scope and asks remote hosts for their archived sessions; scope= still works.

With facets=1, facets counts each facet field's values (field → value → rows) over the list's scope before q. facets_partial: true when some remote host's rows were read one page only (an unfiltered workflow-run listing), so its counts cover that page.

Mutations are recorded, not done

Runs are detached processes. A control endpoint's 200 means the request was recorded (a marker, a decision, a signal sent) — watch the run's status on a stream to see it take effect.

Server-sent events

Four endpoints stream text/event-stream:

EndpointFrames
GET /api/events/streamWithout run: the firehose — every orchestrator run's step events, merged. With run=<id>: one run.
GET /api/runs/:id/logOne run's step events.
GET /api/transcript/stream?path=…One transcript's events.
GET /api/{runs,sessions,agentiflows}/:id/messages/streamOne message space's log. See Messages.

Every frame is an unnamed data: line carrying one JSON event. On the one-run and transcript streams it also carries an id: — the event's line number in its file — and the stream ends with a named end event (see below). The firehose has neither.

id: 2
data: {"type":"step_started","run_id":"01JEXAMPLE...","step_id":"triage",...}

: keep-alive

id: 9
data: {"type":"run_completed",...}

event: end
data: {}

Behaviour worth knowing:

curl -N -H "Authorization: Bearer $CP_TOKEN" \
  "http://127.0.0.1:7878/api/events/stream?run=01JEXAMPLE0000000000000000"

Runs

MethodPathPurposeNotes
GET/api/runsEvery orchestrator run, newest first (RunListRow + host_id).offset, limit, host, customer.
GET/api/runs/workflowsManually launched workflow runs (no cron or event wake).Also lifecycle (active, completed, failed), since, until (honoured on the local host), customer, q (is:archived lists the archive), facets.
GET/api/runs/archivedArchived runs.kind=workflow keeps manual runs only. Local only, unpaged.
GET/api/runs/agentsStandalone and session agent runs (AgentRunRow).offset, limit, lifecycle, host (all default, local, or an id), since, until, customer, q, facets. Offline hosts are skipped on fan-out.
GET/api/runs/autoflowsAutoflow cycle ticks (AutoflowCycleRow).offset, limit, host, since, until.
GET/api/runs/autoflows/eventsActionable autoflow events: run launched, awaiting a human, cycle failed (AutoflowEventRow).Same parameters.
GET/api/runs/:idRun detail: {run, steps, usage}.host. 404 unknown.
DELETE/api/runs/:idHard-delete a finished run.host. 409 if the run isn't terminal ("cancel it first").
POST/api/runs/:id/archiveMove a finished run to the archive.host. 409 if not terminal or already archived.
POST/api/runs/:id/restoreMove an archived run back.host. 404 not archived; 409 already present.

Run control

All take host. Locally they return the updated run detail plus host_id; remotely {ok, host_id}. Their JSON bodies are optional: an empty body means none, and a body that isn't the expected JSON is a 400.

MethodPathPurposeNotes
POST/api/runs/:id/approveApprove a parked gate.gate=<step_id> picks one gate when several are parked. Optional body {mode: "ask"|"bypass"|"readonly"}. Records the decision and a resume marker; the cp serve resume worker (or the run's live runner) continues the run. 409: not awaiting approval, gate expired, gate already decided, ambiguous gate (several parked, no gate), unknown gate.
POST/api/runs/:id/rejectReject a parked gate.gate. Optional body {reason?}, as for approve and cancel. Same 409s.
POST/api/runs/:id/cancelCancel a run.Optional body {reason?}. A live runner process is sent SIGTERM and the run is marked Cancelled; a run parked at a single gate with no live runner is rejected instead. 409 already finished.
POST/api/runs/:id/pausePause a running run.Writes a .pause marker the detached runner polls; it stops at its next safe boundary. 409 not running. 501 when the host's transport can't pause.
POST/api/runs/:id/resumeResume a paused run.Marker only; the resume worker spawns rupu workflow resume --if-unfinished. 409 not paused; 501 without rupu cp serve or when the transport can't resume.
POST/api/runs/:id/retryRetry a failed, rejected or cancelled run → 202 {run_id, retrying, restart_interrupted, host_id}.Optional body {restart_interrupted?, mode?}. Records the retry on the run first (its resume marker, under the run lock), then spawns a detached rupu workflow resume <id> (an operator's retry, never --if-unfinished), plus --restart-interrupted when set, which starts interrupted steps and for_each units over instead of continuing them. It resumes in mode (bad → 400), else the run's own launch mode, so a readonly run retries readonly. The child flips the run back to Running under the run lock and consumes the marker; the run's status is the confirmation. A marker still on a finished run is a retry that never took it, and the child's stderr is in <run dir>/retry.log. A run left Running by a dead runner is marked Failed first and retried. 409 running, pending, paused (use resume), awaiting approval or completed, and for a standalone agent run (agent:<name>; continue it instead); 404 unknown. Local only: host=<remote> is a 501.

Run views

MethodPathPurposeNotes
GET/api/runs/:id/graphThe run's DAG with step results, fan-out units, usage and codenames.host.
GET/api/runs/:id/logSSE of the run's step events.host. See Server-sent events.
GET/api/runs/:id/usageLive usage: totals, per-step, per-call points.host, since, epoch — pass back the previous response's epoch to receive only new points.
GET/api/runs/:id/usage-timelineTokens per model call (TurnPoint): turn (1-based, run-wide), label (step id), tokens_*, and where and when the call happened: transcript (the transcript file it is in, as step results and units record it), transcript_turn (1-based: the N-th non-compaction usage event of that file; absent for a compaction call) and at (when the call finished, from the usage ledger; absent for runs that predate the ledger).host. The newer keys are absent from older hosts.
GET/api/runs/:id/childrenEvery child the run launched with dispatch (all kinds), from the run's and its steps' children ledgers (children.jsonl) and their children's, each with its parent_run_id and depth. Pre-ledger sub-agents are read from sub/ and marked legacy. Each child carries round (the agentiflow lead round that launched it; a child deeper in the tree takes its root ancestor's; absent for a child launched outside a lead and for one launched before rounds were recorded), generated (true for a workflow dispatched from a file workflows.generate wrote) and output_excerpt (the first 240 characters of its output). The walk reads at most 2000 run directories; past that the response carries truncated: true (omitted otherwise) and the children of the runs it didn't reach are missing. GET /api/agentiflows/:id/children is the same shape for a flow.Local runs only (no host).
GET/api/runs/:id/boardThe run's board (BoardView): claims, directives, journal.host. A run_… id (anything else is a 404). See Board.
GET/api/runs/:id/messagesThe run's message space.See Messages.
GET/api/runs/:id/autoflowAutoflow provenance: repo, issue, claim, prior cycles.404 means the run has no autoflow context.
GET/api/runs/:id/coverageHost-internal. The run's raw coverage.jsonl (application/x-ndjson); empty when none.Read by a coordinator merging a remote unit's findings (run.coverage_stream feature).
GET/api/runs/:id/sourceA line-numbered slice of a file in the run's workspace (SourceSlice).path (workspace-relative), line, context (default 20, max 200), host. A missing, binary, > 2 MiB or remote file is 200 with available: false and a reason. 400 on path traversal.
GET/api/runs/:id/astThe tree-sitter syntax subtree at a position (AstResponse).path, line, col (1-based), host. Soft-fails like source when there's no grammar.
GET/api/runs/:id/netflowSee Netflow.

Events and transcripts

MethodPathPurposeNotes
GET/api/events/streamSSE firehose, or one run with run.run, host (a remote host requires run).
GET/api/eventsRecent step events, newest first, each with ts, pos and its run's workflow, project and run_codename when known.limit (default 200), cursor before_ts (unix ms), before_run, before_pos, q (fields group, type, run, project, workflow, agent, step, codename, provider, model, severity, message, since, until). Scans the 20 newest runs, or the 20 newest a query's run:, project: and workflow: terms keep; q filters before limit cuts the page.
GET/api/transcriptRead a transcript: {events, summary, unparsed, partial?}.path (a .jsonl under the global dir or a registered workspace), host, run. A missing local file is an empty 200. With host naming an SSH, tunnel or bucket host, a path that isn't one of that host's mirrored transcripts must lie under the same local roots (400 otherwise). For a remote run the coordinator's mirror is served; partial: true when the host couldn't be reached to finish it; 502 when unreachable with nothing cached.
GET/api/transcript/streamSSE tail of a transcript.path, host, run — run is required for a remote transcript not mirrored yet. 502 host unreachable.
POST/api/transcripts/:id/archiveArchive a standalone agent-run transcript.host, ignore_liveness (skip the "still running" check when a PID was reused). 501 without cp serve.
DELETE/api/transcripts/:idDelete a standalone transcript.Same parameters.

Sessions

MethodPathPurposeNotes
GET/api/sessionsSessions, most recently updated first.offset, limit, scope (active / archived), host, since, until, customer, q, facets.
GET/api/sessions/:idSession detail with usage and codename.host. A local session also carries compacting (a compaction is running), compaction (its marker {id, pid, pid_start, started_at}, else null) and last_compaction (see compact).
GET/api/sessions/:id/runsThe session's turns for the chat view (SessionRunRow).host.
GET/api/sessions/:id/usage-timelineTokens per model call.host.
GET/api/sessions/:id/messagesThe session's message space.See Messages.
POST/api/sessions/:id/sendSend a prompt as the next turn, or invoke skills in it.host. Body {prompt?, skills?} → {run_id, host_id}; watch the turn with /api/transcript/stream. skills (skill entries) become rupu session send --skill; the prompt may be empty when a skill is given. 400 with neither, a malformed or wildcard entry, or a skill the session can't take (unknown, ambiguous, held from the other layer; the body is the CLI's message); 404 unknown session; 409 session stopped; 501 without cp serve. A remote host that does not advertise session.skills refuses a send with skills: 501, "upgrade rupu there", and nothing reaches it.
POST/api/sessions/:id/archiveArchive a session.host.
POST/api/sessions/:id/restoreRestore an archived session.host.
DELETE/api/sessions/:idDelete a session.host.
POST/api/sessions/:id/stopStop a session (rupu session stop): end its worker and running turn, mark it stopped.Local only (host other than local is 501). → {ok, id} once the record says stopped. 404 unknown session; 409 archived; 501 without cp serve. A later send reactivates it.
POST/api/sessions/:id/compactCompact the stored conversation now (rupu session compact).Local only. Body {window?} (tokens to size against, in place of the session's own). Recorded first: a marker (<session dir>/compaction.json, under session.lock) names the CP, then the child it starts, and while that process lives send, stop, archive, DELETE and another compact answer 409 (a marker whose process is gone is stale and cleared). Started detached: 202 {ok, id, compaction_id, log}. The child writes the compacted history only if the session record is unchanged since it read it (else it discards the summary); how it ended is last_compaction {id, outcome: compacted|nothing_to_compact|discarded|failed, detail, finished_at} on the session detail, and errors are in log (<session dir>/compact.log). rupu session compact in a terminal takes the same marker. 404 unknown; 409 a turn or a compaction is running, or the session is archived; 501 without cp serve.

Start a session with POST /api/agents/:name/session.

Messages

The operator's side of a run's message space (see Messaging): read what was said in it, and send into it as the operator, the way rupu message list|send does. The same three endpoints exist under each root that has a space:

RootPath prefixId
A workflow run or a standalone rupu run/api/runs/:id/messagesrun_…
A session/api/sessions/:id/messagesses_…
An agentiflow/api/agentiflows/:id/messagesaf_…

An id is served only under the prefix its kind names: a session id under /api/runs is a 404, as is an unknown id.

MethodPathPurposeNotes
GET…/messagesThe space: MessagesView {participants, messages, legacy, space, next}.after (a message id; the page starts after it), limit (messages per page; all when absent), kind (comma-separated: post, direct, broadcast, directive, retract, operator; anything else is a 400), host. Each message is a log line with its seen receipts ({by, turn, at}) folded in. space is absent when nothing has joined or written yet. legacy: true is an agentiflow from before the log: its board posts and directives in the log's shape.
POST…/messagesSend as the operator.host. Body {to?, body, urgent?} → 202 {id, delivery}, where delivery is {resolved, undelivered?} and each undelivered recipient carries its reason (e.g. participant_left). to is a participant, a codename, role:<agent>, step:<id>, lead or broadcast; absent, it is an agentiflow's lead and everyone elsewhere. urgent puts the message first, and ends an agentiflow lead's round early. 400 an empty body or a bad address; 404 unknown id; 409 the run isn't running (finished, or its recorded runner process is gone), or its log is full; 501 without rupu cp serve. The message is recorded with via: "cp". The CP has no per-person identity, so viewer is never set.
GET…/messages/streamSSE tail of the space's log.This host's runs only: with a remote host it is a 501 (poll GET …/messages?host= instead). See below.

POST /api/agentiflows/:id/steer is kept as an alias. Without stop it is exactly POST /api/agentiflows/:id/messages {to: "lead", body: message, urgent: now}, answered the same way. With stop: true it queues a graceful stop on the run's control queue instead (see Agentiflows).

The stream replays the log from the start, then tails it, as the other streams do. Each frame is an unnamed data: line with an id: (the line's number in the log; Last-Event-ID resumes after it). The data is one of:

The stream stays open after the run ends (a finished run's space can still be read); close it when you're done.

curl -X POST -H "Authorization: Bearer $CP_TOKEN" -H 'Content-Type: application/json' \
  -d '{"to": "step:review", "body": "skip the vendored code", "urgent": true}' \
  "http://127.0.0.1:7878/api/runs/run_01JEXAMPLE0000000000000000/messages"
# 202 {"id":"msg_01JEXAMPLE...","delivery":{"resolved":["heron#2"]}}

Remote hosts

With host=<id>, GET and POST go to that host. The stream does not.

TransportMessages
localServed here.
HTTPProxied to the host's own endpoints when its /api/host/info lists the run.messages feature. The coordinator answers from the features it last read (/api/hosts probes refresh them), so a host upgraded since that read is still a 501 until the next /api/hosts probe picks up its run.messages. It reads /api/host/info only for a host it has never read. A host that doesn't list it is a 501, "upgrade rupu there". The host's 400, 404 and 409 come back as the same status.
SSHOne remote command runs rupu __features. Only when the report lists run.messages does it run the hidden rupu __message list|send. Any other remote is a 501, naming why: rupu isn't on its PATH, or its rupu predates run.messages. The helper's refusals keep their status.
Tunnel, bucket501 with a reason: these transports carry no messages. Message the run on its own host.

A host that can't be reached is a 502, as for any remote read.

Launch, agents and workflows

Launches return as soon as the detached process is spawned; all are 501 without rupu cp serve.

MethodPathPurposeNotes
POST/api/launch/previewWho a launch would bill and authenticate as (PreviewResponse).Body {workflow or agent, working_dir?, scope_kind?, scope_id?, host?}. 409 when the launch itself would fail to resolve (dangling customer, bad config). See Launch preview.
GET/api/workflowsWorkflow definitions, global and per project (WorkflowDto).
POST/api/workflowsCreate a workflow in the global layer.Body {raw} (YAML). 409 if the name exists.
GET/api/workflows/:name{workflow, yaml, usage, scope, scope_kind, scope_id}.
PUT/api/workflows/:nameOverwrite a workflow.scope_kind, scope_id. Body {raw}; the YAML's name must match :name.
DELETE/api/workflows/:nameDelete a workflow.scope_kind, scope_id.
POST/api/workflows/:name/runLaunch a run → {run_id, host_id}.Body LaunchBody {inputs, mode, target, working_dir, host, scope_kind, scope_id, engagement_profiles}. scope_kind and working_dir are mutually exclusive; scope pinning is local-only. engagement_profiles (ids; blank → 400) become rupu workflow run --engagement-profile; local only (501 with a remote host).
POST/api/workflows/runLaunch a workflow given as YAML, not saved under a name → {run_id, host_id, file}.Body {yaml, inputs?, mode?, working_dir?, engagement_profiles?, host?}. The YAML is parsed first (400 with the parse error, nothing written), then kept as <global>/cp/workflow-launches/<ulid>.yaml (the run's source) and run with rupu workflow run --file; the run is named by the YAML's name:. Local only: a remote host is a 501.
POST/api/workflows/validateParse-check YAML without saving.Body {raw}. 400 with the parse error.
POST/api/workflows/generateDraft a workflow from a description with a model.Body {description, provider?, model?}.
GET/api/generate/modelsProviders and models available for generation.
GET/api/agentsAgent definitions, global and per project (AgentDto).Each row carries its frontmatter skills and preload_skills (omitted when empty) and, when it can't be launched, load_error: its tools: names an unknown tool, or a granted skill is not installed, does not load, or is ambiguous, checked as rupu agent validate checks it against the layers of the row's scope (global; global plus the project for a project agent).
POST/api/agentsCreate an agent in the global layer.Body {raw} (the .md file). 409 if the name exists. Checks skills: / preloadSkills: entries as PUT does.
GET/api/agents/:nameAgent detail with its prompt and raw file.load_error as in the list.
PUT/api/agents/:nameOverwrite an agent.scope_kind, scope_id. Body {raw}. 400 for a malformed skills: / preloadSkills: entry (skills: entry …); a skill that isn't installed is accepted and shows in load_error.
DELETE/api/agents/:nameDelete an agent.scope_kind, scope_id.
POST/api/agents/:name/runLaunch an agent run → {run_id, host_id}.Body AgentRunBody {prompt, mode, target, working_dir, host, scope_kind, scope_id, findings_profile, engagement_profiles, skills, preload_skills, skill_bundle, messaging, run_id, codename, model, provider}. model / provider (blank → 400) become rupu run --model / --provider, over the agent's own; local only (501 with a remote host). engagement_profiles (ids; blank → 400) becomes rupu run --engagement-profile; a remote host that does not advertise agent.engagement_profile refuses it. skills / preload_skills (skill grant entries; malformed → 400) become rupu run --skill / --preload-skill; a remote host that does not advertise agent.skills refuses them. skill_bundle (a manifest {version, skills: [{layer, name, content_hash}]}; invalid → 400) becomes rupu run --skill-bundle; a coordinator sets it for a placed unit after shipping the skills, and a remote host that does not advertise agent.skill_bundle refuses it. messaging is a placed unit's workflow messaging: switch: "off" becomes rupu run --no-messaging and {"tools": [...]} one rupu run --messaging-tool per name (a name that isn't a board.* / msg.* tool → 400); absent, the agent file decides. A remote host that does not advertise agent.no_messaging (for "off") or agent.messaging_tools (for tools) refuses it. run_id (a run_… id; malformed → 400) is the id the run executes under instead of a minted one (advertised as run.supplied_run_id); codename (malformed → 400) becomes the hidden rupu run --codename (advertised as run.codename). A coordinator sends either only to a host that advertises it, and a codename a host can't take never blocks the launch.
POST/api/agents/:name/sessionStart a session → {session_id, host_id}.Body SessionStartBody {prompt, mode, target, working_dir, host, scope_kind, scope_id, skills, preload_skills}. skills / preload_skills (skill grant entries; malformed → 400) become rupu session start --skill / --preload-skill; a skill the session can't resolve is a 400 with the CLI's message. A remote host that does not advertise session.skills refuses them: 501, "upgrade rupu there".
POST/api/agents/generateDraft an agent from a description.Body {description, provider?, model?}.
POST/api/agents/validateCheck an agent file's text without saving it → {ok: true, name}.Body {raw, ws_id?}. It must parse, its tools: must name known tools and its skills must resolve in the global layer plus ws_id's project layer: what rupu agent validate checks. 400 naming what's wrong; 404 unknown ws_id.
GET/api/agents/validateCheck every agent file, as rupu agent validate --all does → {ok, rows}.ws_id checks only that project's files beside the global ones; absent, every registered project (one workspace per repo). Each row is {name, layer, scope_id?, path, ok, error?}; a failing file is a row, and ok is false when any failed.
POST/api/runs/agents/:id/continueContinue an agent run from its transcript → 202 {run_id, continues, host_id}.:id is a standalone run_… or a sub-agent's sub_…. Optional body {prompt?, model?, provider?, mode?, findings_profile?, engagement_profiles?, skills?, preload_skills?, messaging?}. Spawns a detached rupu run <agent> --continue <id> [--prompt=<prompt>] under a new run id, in the directory the run was launched from (its recorded workspace, not a repo target's clone, so the original checkout is reused); the child restores every flag the run was launched with, and each field given replaces its own (mode ask/bypass/readonly; findings_profile full/summary; engagement_profiles, skills, preload_skills replace the whole list, an empty list keeps the launch's; messaging "off" or {"tools": [...]}). prompt is what the run is told next, as rupu run --continue takes one: with it, any ended run carries on — an interrupted run gets the interruption note then the prompt, a finished run gets the prompt as a new turn (a real model call), a failed run continues on the same model. Without it, a run that failed is continued only with model or provider, and a run that had finished answers with its recorded answer. The prompt is never carried into a later continue: it is in the new run's conversation. 409 while it is still running (for a sub-agent: its record in the dispatching run's children.jsonl has no end and that process is alive) or when the directory it was launched from is gone; 404 unknown; 400 for a blank prompt / model / provider, a bad mode or findings_profile, a blank engagement id, a malformed skill entry or an unknown messaging tool. Local only: host=<remote> is a 501.
curl -s -X POST -H "Authorization: Bearer $CP_TOKEN" -H 'Content-Type: application/json' \
  -d '{"inputs":{"pr":"42"},"mode":"readonly"}' \
  http://127.0.0.1:7878/api/workflows/review-pr/run
# {"run_id":"01JEXAMPLE0000000000000000","host_id":"local"}

Skills

Installed Agent Skills. The reads take the global layer and, with ws_id, that project's layer (.rupu/skills in the workspace; none when the project's .rupu is the global dir). An unknown ws_id is a 404.

MethodPathPurposeNotes
GET/api/skillsEvery installed skill, as rupu skill list (SkillsResponse).ws_id. Rows sorted by name, then layer. Each row loads its skill to show its status.
GET/api/skills/:layer/:nameOne skill, as rupu skill show (SkillDetail).ws_id, required for project. 400 for a layer other than global / project, or project without ws_id; 404 no installed skill <layer>:<name>; 500 when the skill's layer can't be read.

The writes run what rupu skill install|update|validate run, through the same rupu_skills functions (an install is staged, validated and renamed in by install_from). They are local only: a host other than local is 501. Install and update need cp serve (501 otherwise), which provides the git fetch.

MethodPathPurposeNotes
POST/api/skills/installInstall skills (rupu skill install).Body {source, ref?, skills?, all?, project?, force?, dry_run?}: source is an absolute directory, owner/repo, a GitHub tree URL or an https git URL (#path narrows it); project is a ws_id (its .rupu/skills). → {installed: [{name, layer, path, outcome: created|replaced, content_hash, tracked}], skipped: [{name, reason}]}; tracked is false for a project install from a directory outside the project (update can't follow it). 400 bad source, relative directory, unknown or invalid skill, skills with all; 404 unknown project; 409 a skill of that name is installed and force isn't set, or the project has no .rupu/; 502 the fetch failed. A batch that fails part-way names what landed in its error. dry_run: true installs nothing and needs no writable config: it answers {dry_run: true, layer, found: [{name, rel, description, status, reason?}]}, one row per skill in the source, status new / up_to_date / changed (installed as recorded, the source moved on) / locally_modified (the installed copy is not the one its record names, has no record, or doesn't load: replacing it needs force) / invalid, with reason for the last two.
POST/api/skills/updateRe-fetch installed skills from their source (rupu skill update).Body {names?, project?, force?}; no names = every skill with a source record. → {layer, results: [{name, outcome, from?, to?, error?}]}, outcome updated / up_to_date / no_record / locally_modified / not_installed / failed: per skill, never an error status.
POST/api/skills/validateCheck skills against the format (rupu skill validate).Body {path} (an absolute directory: every skill under it, installed or not) or {name, layer?, ws_id?} (an installed skill, both layers without layer). → {ok, skills: [{name, layer?, path, status, error?, warnings}]}. 400 neither or a relative path; 404 no installed skill of that name.

Hosts receive the skills of a placed unit through two more routes, behind the agent.skill_bundle feature in /api/host/info (see Skills on placed units). They write only to <RUPU_HOME>/cache/skills.

MethodPathPurposeNotes
POST/api/skills/bundle/missingWhich shipped skills this host lacks.Body {skills: [{layer, name, content_hash}]} → {missing: [...]}. 400 for a malformed ref.
PUT/api/skills/bundle/:hashStore one packed skill (the tar is the body).layer, name. The tar is vetted and must hash to :hash; 400 otherwise, nothing kept. A hash already cached is a success. Body limit is the per-skill cap (32 MiB) plus 64 KiB for the tar headers.

SkillsResponse is {skills: [SkillRow], warnings: [string]}. A layer that can't be read is left out of skills and named in warnings ("project layer: <path>: <error>"); the response is still 200. A row:

Field
name, layerThe directory name and global / project.
statusok or invalid.
description, stampFor a valid skill; stamp is the short stamp.
errorWhy an invalid skill doesn't load (a name mismatch, a SKILL.md over 1 MiB, a symlinked project skill or project skills/, …).
warningsFrontmatter warnings (an over-limit description), and install record: <error> when the skill's install record can't be read.
sourceThe install record's source, when there is a readable one.
both_layersThe other layer has a skill of the same name, so a bare grant of it is ambiguous.

SkillDetail is the row's fields plus path, license, compatibility, allowed_tools, mapped_tools (the rupu tools allowed-tools maps to; informational, never a grant), disable_model_invocation, metadata, files (relative to the skill, without SKILL.md), content_hash (short), body (SKILL.md without its frontmatter), source_record (the install record as written) and source_error (why the record couldn't be read). The fields that need a loaded skill are empty for an invalid one. An install record is read only when it is a regular file of at most 64 KiB, and never through a symlinked project skills/.

Autoflows

MethodPathPurposeNotes
GET/api/autoflowsWorkflows with an autoflow: block, with trigger kind and enabled state.
POST/api/autoflows/:name/enableSet autoflow.enabled: true in the file (a .bak is kept).scope_kind, scope_id. The file is <name>.yaml, else <name>.yml (as the list and the autoflow runtime read both; GET /api/workflows/:name resolves the same way). 404 not an autoflow.
POST/api/autoflows/:name/disableSet it to false.Same.
GET/api/autoflows/claimsIssue claims held by the entity engine (ClaimRow).
POST/api/autoflows/claims/releaseDrop a claim.Body {issue_ref} → {released}.
POST/api/autoflows/claims/requeueQueue a manual wake for a claimed issue.Body {issue_ref, not_before?} → {wake_id}. 404 no claim.
GET/api/autoflows/:nameOne autoflow's resolved metadata and YAML: rupu autoflow show's autoflow_show report.repo picks one when several repos define the name. 404 none visible, 409 several.
GET/api/autoflows/explainOne issue's autoflow state: claim, last run, queued wakes, recent cycle events (autoflow_explain).issue_ref (required, the full <tracker>:<project>/issues/<number> form — a bare 42 / #42 is a 400; a query parameter because an issue ref carries /), repo.
GET/api/autoflows/doctorConsistency problems across claims, wakes and runs (autoflow_doctor: ok, rows).repo.
GET/api/autoflows/wakesQueued wakes and the ten most recently processed (autoflow_wakes).repo.
POST/api/autoflows/:name/runRun one autoflow cycle for an issue now (rupu autoflow run, detached).Body {issue, repo?, mode?} (issue in the full <tracker>:<project>/issues/<number> form, mode bypass|readonly). Checked first, then started: 404 the autoflow doesn't resolve for the issue, 409 several do or another cycle holds the issue's claim, 400 a bad issue or mode. 202 {started, name, issue, pid, log} once the child runs; log keeps its stderr. Follow it with GET /api/autoflows/explain.
POST/api/autoflows/claims/repairrupu autoflow repair: drop the claim's invalid queued wakes, rebuild its missing worktree, then release it or queue a repair wake.Body {issue_ref, release?, requeue?, dry_run?}. dry_run defaults to true: it lists the repairs (would …) and changes nothing. → {issue_ref, dry_run, repairs}. 400 release with requeue, or a ref that isn't the full form (a bare number is refused), 404 no claim.

Every autoflow read and write above except the definitions list and enable/disable runs on this control plane's own RUPU_HOME only: a host other than local is a 501. They answer with the JSON report the matching CLI command prints under --format json, from the same library (rupu-autoflow), on any control plane. Autoflows are visible from the directory rupu cp serve started in, as from the CLI's working directory. Starting a cycle (POST /api/autoflows/:name/run) needs rupu cp serve (501 otherwise), which spawns it; a write that isn't a dry run also needs it as the write gate. GET /api/autoflows/:name never shadows the literal claims, doctor, wakes and explain routes.

Cron and maintenance

Local host only (host other than local → 501). They read the global layer and the project layer of the directory rupu cp serve started in, as its cron loop does. The listings and every prune work on any control plane; the tick needs rupu cp serve (501 otherwise), which runs the workflows.

MethodPathPurposeNotes
GET/api/cronCron-triggered workflows and when each fires next: rupu cron list's cron_list report.next_utc / in_seconds are null when the schedule can't be computed.
GET/api/cron/eventsEvent-triggered workflows, the configured poll sources and their latest cursors (cron_events).
POST/api/cron/tickRun the cron + event-trigger tick cp serve's loop runs, now.Body {dry_run?, skip_events?, only_events?}. A real tick fires due workflows in the background → 202 {started: true, dry_run: false}. A dry run → 200 {kind: "cron_tick", dry_run: true, would_fire: [{tier, workflow, …}]} and changes nothing. One tick at a time per RUPU_HOME: a real tick holds an exclusive lock on <RUPU_HOME>/cron-state/tick.lock, as do cp serve's cron loop and rupu cron tick, so this answers 409 while any of them (in this process or another) ticks, and the loop skips its turn while a requested one runs. A dry run takes no lock. 400 skip_events with only_events.
POST/api/maintenance/pruneDelete old archived transcripts, archived sessions or netflow ledgers.Body {kind, older_than?, dry_run?}; kind is transcripts (rupu transcript prune), sessions (rupu session prune), netflow (rupu netflow prune) or cleanup (rupu cleanup: sessions and transcripts). dry_run defaults to true → {kind, dry_run: true, would_remove: [...], errors}; dry_run: false deletes → {kind, dry_run: false, removed: [...], errors}. The rows are the CLI report's rows; errors names files or directories that couldn't be removed or scanned (netflow). older_than (30d, 12h, 1w) overrides the default retention; a bad one is a 400, a bad configured [storage] retention a 500. A real prune (dry_run: false) needs the write gate.

Findings

Query language, tags and report export are covered in depth in Findings.

MethodPathPurposeNotes
GET/api/findingsEvery finding across all workspaces.Query: ws_id, workflow, run_id (a run or agentiflow id: it and everything that ran under it — steps, fan-out and flow units, launched children of every kind, down the whole tree, live units included), customer (the project's current assignment), q (query language, e.g. severity>=high tag:triage). Rows are slim: no report body, a report_summary on full-profile rows. Response also carries summary, facets (counts over the scope-filtered, unqueried set) and tags_unavailable. A bad q is a 400 {error, token, code, start, end}.
GET/api/findings/:idOne finding in full.ws_id. Adds per-claim evidence_status (current / changed / missing / unknown), tag_history, tags_editable. 404 unknown id; 409 when the id exists in several workspaces and no ws_id picks one (the message names them).
GET/api/findings/:id/exportOne finding as a report file.format=md|html|pdf (required), ws_id. 400 bad format; 501 PDF in a build without the pdf feature; 404; 409 ambiguous id, as above.
POST/api/findings/exportSeveral findings as one report.Body ExportBody: format (required), title, ids, ws_id, run_id, min_severity, owner, cwe, include_summaries, split. Returns text/markdown, text/html, application/pdf, or application/zip when split; always an attachment with nosniff. 404 when nothing matches.
GET/api/findings/tagsTags in use, most used first.Query: ws_id.
POST/api/findings/importAttach Markdown reports written before the full profile to their findings: what rupu findings import runs.Local-only (host other than local → 501). Body {paths, id?, dry_run?}: paths are absolute paths on this machine, report files or directories searched for *.md (hidden entries skipped, symlinks not followed); id names the finding of a single report with no Finding ID: line. Each report attaches to the summary finding it names, whole or not at all; a finding that already has a report is left alone. Every changed ledger is backed up first (findings.jsonl.pre-import-<time>) and written under the findings lock; dry_run writes nothing (no change, backup or lock file) and needs no rupu cp serve, a real import does (501). Limits are the global config's [findings]. 200 ImportReport {dry_run, files: [{path, status: attached|skipped|failed, finding_id | reason, details?}], backups, attached, skipped, failed, warnings?}; a file that could not be imported, a named but unreadable one included, is a failed line, not an error status. 400 for a relative or missing path, or a directory that cannot be listed; for a search that passes 100,000 directory entries (name narrower paths); for an id with more than one file; or for nothing to import.
GET/api/findings/schemaThe finding report JSON Schema embedded in this build: what rupu findings schema prints.advertised=true returns the simplified copy used in tool definitions instead of the canonical draft-07 schema.
POST/api/findings/tagsAdd / remove tags on many findings.Body {finding_ids, add, remove} — each id tagged in every workspace that holds it — or {findings: [{ws_id, id}], add, remove} — each finding tagged only in its own workspace (what the web sends). Exactly one of finding_ids / findings; at most 1000. Atomic per workspace; response TagAcrossResult {workspaces, unknown} — a workspace whose tag log couldn't be written carries its error there. 400 for a bad change (empty, conflicting, too many tags); 500 if writing the tag log fails outright. 404 only when every finding is unknown. Recorded as via: cp.
GET/api/findings/:id/artifacts/:sha256An artifact or evidence-block file the finding references.ws_id; 409 for an ambiguous id, as above. Text inline as text/plain, raster images inline as image/*, everything else as an attachment; always nosniff + Content-Security-Policy: sandbox. 404 if the finding doesn't reference it; 409 if a referenced workspace file changed since it was recorded; a remote artifact is pulled from its host on first view, and a failed pull is 404 {"unavailable": reason}.
GET/api/findings/artifacts/:sha256Host-internal. Raw blob from this host's artifact store.Used by a coordinator pulling a remote unit's artifact (findings.artifact_blob feature). Not finding-scoped. 404 if not in the store.

Coverage and assets

All local-only. ws_id is optional; without it the first workspace containing the target is used.

MethodPathPurposeNotes
GET/api/coverageEvery coverage target across workspaces.CoverageSummary rows.
GET/api/coverage/templatesBundled concern templates.TemplateSummary rows.
GET/api/coverage/templates/:nameOne template.404.
GET/api/coverage/:targetTarget detail: assertions, findings, files touched.ws_id. 404 unknown target.
GET/api/coverage/:target/catalogThe concern catalog snapshot (FlatCatalog).ws_id.
GET/api/coverage/:target/auditPer-concern gap audit (AuditReport).ws_id.
GET/api/coverage/:target/runsRuns that contributed to the target.ws_id.
GET/api/coverage/:target/diffTwo runs' contributions side by side (RunDiff).ws_id, base (default previous), compare (default latest, or a run id). 400 unknown run.
GET/api/coverage/:target/gapThe coverage gaps alone: each in-scope file a concern has no assertion for, what rupu coverage gap prints.ws_id. {kind: "coverage_gaps", version: 1, rows: [{concern_id, file}]}, in concern order. 404 unknown target; host other than local → 501.
POST/api/coverage/:target/rerunReplay a recorded agent run on its target, what rupu coverage rerun launches: a new run appended to the same target.ws_id. Body {run}: a full run id from …/runs. The run's agent, prompt, permission mode and launch skills start again through the agent launcher, detached, in the workspace the run was recorded in; provider, model and concerns come from the agent's current file and config, as on the CLI. 202 {run_id, host_id, agent, target_id, ws_id, replays} once started; diff the two with …/diff?base=<run>&compare=latest. 404 unknown target or no manifest for the run; 409 a run that continued another (rerun that one) or was recorded in another workspace; 501 a session, workflow or autoflow run, a host other than local, or no rupu cp serve.
GET/api/assetsAsset inventory (AssetListResponse).ws_id, target.

Query help

What each list's q takes, and a check of a query without running it. The registries are the ones the lists filter with, so the answer is the list's own; the field shapes are the ones crates/rupu-cp/tests/fixtures/list_query/ and crates/rupu-coverage/tests/fixtures/finding_query/fields.json pin. :list is findings, workflow_runs, agent_runs, sessions, autoflow_runs or events; any other is a 404 naming them.

MethodPathPurposeNotes
GET/api/query/:list/fieldsThe list's query fields, in suggestion order.{list, fields}; each field has key, aliases, kind (severity, enum, text, time or a custom kind such as tag) and allowed values; the run, session and event lists also give match, facet and text.
POST/api/query/:list/checkParse a query with the list's fields.Body {q}. 200 {ok: true, terms: [{neg, key, op, values}]} (aliases resolved, values normalized), or 200 {ok: false, error, token, code, start, end}: the body of the list's own 400 for that query.
curl -s -X POST -H "Authorization: Bearer $CP_TOKEN" -H 'Content-Type: application/json' \
  -d '{"q":"sev>=high -tag:noise"}' http://127.0.0.1:7878/api/query/findings/check
# {"ok":true,"terms":[{"neg":false,"key":"severity","op":"ge","values":["high"]},{"neg":true,"key":"tag","op":"eq","values":["noise"]}]}

Usage and dashboard

since / until are RFC 3339 (default: the last 30 days). A value that doesn't parse is a 400.

MethodPathPurposeNotes
GET/api/usageToken and cost totals with a breakdown (UsageResponse).since, until, group_by (provider, model (default), agent, workflow, host, project), host, customer. Without host, fans out to every host and reports each one's freshness in hosts. 404 unknown host; 501 for ?host=<remote>&customer= (a remote aggregate arrives already summed).
GET/api/usage/timelineGap-filled spend series.since, until, bucket (day / week), customer. Local only.
GET/api/usage/runsOne row per run per model (UsageRunRow).since, until, workspace_id, customer. Local only.
GET/api/usage/outliersRuns costing at least 3× the median of their workflow (OutlierRun).since, until, customer. Needs ≥ 3 runs for a baseline. Local only.
GET/api/dashboardThe Overview dashboard (DashboardResponse).range (7d, 30d, all), host, customer. Same host / customer rules as /api/usage.

Endpoints that take customer add an X-Rupu-Hosts-Without-Customer response header naming hosts whose rows couldn't be attributed — see Customers → Remote hosts.

Netflow

Netflow reads take from / to (inclusive RFC 3339) and comma-separated dimension filters workflow, origin, org and host. Here host filters by remote endpoint (host:port), not by registered rupu host. Each dimension has an exclusion list too, not_workflow, not_origin, not_org and not_host: a flow whose key is listed is left out. An explorer response built with exclusions says exclusions_applied: true; for a run on an HTTP host older than the exclusions, the explorer answers 501 rather than relay an unfiltered view.

MethodPathPurposeNotes
GET/api/runs/:id/netflowOne run's flows (NetflowResponse).Proxied to the run's host when remote. 404 unknown run.
GET/api/projects/:id/netflowProject-scope flows.incomplete lists remote hosts not included.
GET/api/netflowEvery workspace's flows.
GET/api/netflow/graphSource → endpoint graph (GraphView).scope (run:<id>, project:<id>, absent = global), from, to. Dimension filters are not applied.
GET/api/netflow/explorerExplorer aggregates: sankey, timeline, histogram, KPIs.scope plus the filters above.
GET/api/netflow/indexResident netflow index status (size, budget, evictions).
GET/api/netflow/latestThe most recently started run with flows: {latest: {run_id, workflow, started_at, finished_at} | null}.scope (project:<id>, absent = global; anything else is a 400). A ledger no run record accounts for counts as a run spanning its own flows (run_id: null). finished_at is null while the run is live. The Network page opens on this run's span or the last hour, whichever is shorter.

Projects and code

An unknown ws_id is a 404 on every route except the list.

MethodPathPurposeNotes
GET/api/projectsProjects with rollups (ProjectRow).customer.
GET/api/projects/:ws_idProject overview (ProjectDetail): run/session counts, coverage, 10 recent runs, usage.
GET/api/projects/:ws_id/runsThe project's runs.offset, limit, q, facets.
GET/api/projects/:ws_id/sessionsThe project's sessions, most recently updated first.offset, limit, q, facets.
GET/api/projects/:ws_id/coveragePer-target coverage summary.
GET/api/projects/:ws_id/coverage/assessedAssessed percentage ({assessed_pct}).Computed by a full audit; slower than the summary.
GET/api/projects/:ws_id/agentsAgents the project can use (project overrides global by name).Every row's load_error is checked against the global and this project's skill layers, so a bare skill name installed in both is ambiguous here.
GET/api/projects/:ws_id/workflowsWorkflows the project can use.
GET/api/projects/:ws_id/autoflowsAutoflow definitions the project can use.
GET/api/projects/:ws_id/treeOne directory of the checkout (TreeResult).path (workspace-relative). 400 for an absolute path, .., or a path leaving the workspace. Skips .git, .rupu, node_modules, target.
GET/api/projects/:ws_id/sourceOne file's contents (FileContent).path. A missing, binary or > 2 MiB file is 200 with available: false and a reason.
GET/api/projects/:ws_id/filesFlat file list for search.Capped at 20 000 (truncated).

Stock fleet, project init and engagement profiles

What rupu fleet list|install, rupu init and the run assembler's profile registry do, through the same functions (rupu_templates, rupu_coverage::profile). All local only: a host other than local is 501. The writes need cp serve (501 otherwise).

MethodPathPurposeNotes
GET/api/fleetThe stock security-assessment fleet and where it is installed.ws_id. → {entries: [{kind: agent|workflow, name, path, global, project?}], global_root, project_root}; global / project are missing, current (the shipped file), modified or unreadable.
POST/api/fleet/installInstall the fleet (rupu fleet install).Body {force?, project?}: the global root, or that project's .rupu/; existing files are kept unless force. → {root, created, skipped, overwrote, files: [{path, action}]}. 404 unknown project; 409 the project has no .rupu/.
POST/api/projects/initInitialise a directory as a rupu project (rupu init).Body {path, with_samples?, force?}. path must be absolute and exist (400 otherwise). 409 when .rupu/config.toml exists and force isn't set; force re-runs it, overwriting samples (never config.toml). → {root, created, skipped, overwrote, files}, action created / skipped / overwrote / updated (.gitignore lines appended, named in detail). It doesn't register the project.
GET/api/engagement-profilesThe engagement profiles a launch can select.ws_id overlays that project's .rupu/profiles on <RUPU_HOME>/profiles on the built-ins (a later layer wins by id). → {profiles, errors}: each profile's TOML fields (id, name, includes, asset_kinds, completeness, coverage, bundle, …) plus its layer (builtin / global / project). errors names profile files that don't load (and a missing include); while it's non-empty every engagement launch fails.
GET/api/engagement-profiles/:idOne profile.ws_id. The profile with its layer and the overlay's errors. 404 unknown id.

Hosts, workers and fleet

MethodPathPurposeNotes
GET/api/hostsHosts with health (HostView).Local first, always online. Remote status comes from the probe cache (fresh 15 s; stale is served while one background refresh runs; > 5 min or never probed is awaited).
GET/api/hosts/registeredRegistered hosts with no probe (RegisteredHostView).What the per-host loaders iterate.
POST/api/hostsRegister an HTTP control-plane host.Body AddHostBody {name, base_url, token?}; the token goes to the keychain. 501 without cp serve.
POST/api/hosts/sshRegister an SSH host.Body AddSshHostBody {name, host, port?, identity_file?}.
POST/api/hosts/bucketRegister an object-store bucket host.Body AddBucketHostBody {name, url, prefix?}.
POST/api/hosts/nodeEnroll a tunnel node.Body {name}. Returns EnrollNodeResponse {host, command, token}; the plaintext token is shown once, only its hash is stored. 400 empty name.
DELETE/api/hosts/:idRemove a host.204; a tunnel host's live node connection is closed at once (its reconnect then fails enrollment). 400 for local; 404 unknown id.
GET/api/workersWorkers with run activity (WorkerView).
GET/api/host/infoHost-internal. This host's version, capabilities and feature list (HostInfoResponse).A coordinator's HTTP connector reads features before using an optional capability (e.g. run.messages, Messages).
GET/api/node/connectHost-internal. WebSocket for rupu node tunnel peers.Outside the bearer check. See Node tunnel.

Workspace sync (host-internal)

A coordinator placing a run on an HTTP host ships the workspace, runs there, and pulls back the diff. The web UI never calls these.

MethodPathPurposeNotes
POST/api/workspace/stageStage a packed workspace (rupu_workspace::Payload, raw bytes).Returns {working_dir}. 413 over the compiled 256 MiB cap; 400 over [cp].max_workspace_bytes or a malformed payload.
GET/api/workspace/deltaDiff a staged dir against its baseline (Delta, octet-stream) and remove it.dir. 400 outside the sync root.
DELETE/api/workspace/discardBest-effort cleanup after a failed launch.dir.

Config, models and tools

MethodPathPurposeNotes
GET/api/configEffective config with per-key provenance and each layer's raw TOML (ConfigView).project (ws id) or customer (slug), not both. Includes status {bind, token_set, restart_required_keys} — the token itself is never returned. A malformed layer is 200 with layer_error.
PUT/api/config/globalWrite the global config.Body ConfigWriteBody {raw?, patch?}. 400 invalid TOML. Validated, backed up, written atomically. Response {ok, restart_required}: the start-time-only keys (cp.{autoflow_reconcile,cron_tick,gate_sweep}_{enabled,interval_secs}, agentiflow.reaper_enabled, netflow.asn_auto_refresh — what cp serve's background loops capture at start) whose saved value now differs from the one the server started with; they apply after a restart. Everything else applies at once.
PUT/api/config/customer/:slugWrite a customer's layer.200 {ok: true}. 400 for a key the GLOBAL [policy].lock enforces or a layer that doesn't validate on top of global; 404; 501.
PUT/api/config/project/:idWrite a project's .rupu/config.toml.400 for a locked key (global, or the project's customer: key … is enforced by customer … policy) or a project whose .rupu/ is the global dir.
PUT/api/config/policySet the global [policy].lock.Body {lock: [...]}.
GET/api/modelsModel catalog per provider, with limits and provenance (CatalogProvider).501 without cp serve.
POST/api/models/refreshRefetch provider model lists.Body {provider?}; empty = all. 10 s per provider. 400 unknown provider.
GET/api/toolsThe tool catalog with JSON input schemas (ToolsResponse).Every catalog tool as {name, aliases, namespace, effect, needs, description, input_schema, action_eligible}; action_eligible marks the tools an action: step may call. Feeds the workflow editor's action forms. The same catalog is served to MCP clients at /mcp — see MCP & tools.
GET/api/reposRepositories visible to the configured SCM accounts.501 with no SCM credentials.
GET/api/fs/browseSubdirectories of a server path, for the folder picker.path (default $HOME). Only under $HOME or a registered project: anything else (missing or not) is a 403, and parent is null at the top of a browsable directory.

MCP

rupu cp serve mounts an MCP endpoint at /mcp (streamable HTTP, POST only) beside the web UI and /api, behind the same bearer token and an Origin/Host guard. It serves one operator tool per CP route (the routes on this page, minus the SSE streams, the host-to-host transfer routes, the node WebSocket, /healthz, /api/fs/browse and POST /mcp itself), a wait long-poll in place of the streams, and the tool catalog GET /api/tools lists. rupu cp serve --no-web serves /api + /mcp without the UI, --no-mcp the UI + /api without it. Setup, tool names, resources and prompts are on MCP & tools.

SCM, repos and auth status

What rupu scm accounts / scm bind, rupu repos tracked / attach / prefer / forget and rupu auth status show and write. Local host only: ?host= other than local is a 501 that says so. The SCM endpoints read and write the global config (where rupu scm bind writes). No answer carries any part of a credential: not a token, an API key or a refresh token, and a tracked repo's origin urls (which can embed one) are never returned. Logging in stays rupu auth login. A global config that does not parse is a 500 naming the file, never an answer built from defaults.

MethodPathPurposeNotes
GET/api/scm/accountsThe configured GitHub/GitLab accounts: github, gitlab, then each [scm.<name>] whose kind is a repo platform.Rows {account, kind, base_url, rules, credentials, state}; rules are the [[scm.rules]] selecting it, each {owner} or {path}; credentials is whether an API key or SSO token is stored (or RUPU_<ACCOUNT>_API_KEY is set); state is ok, needs_login (only an expired SSO token with no way to refresh it) or absent; an expired token that has a refresh token is ok, since it is renewed on use.
GET/api/scm/rulesThe global config's [[scm.rules]], in order.Rows {owner?, path?, account}.
POST/api/scm/rulesAppend a rule (rupu scm bind).Body {owner?, path?, account}, exactly one of owner (owner glob) or path (path glob); unknown fields are refused. → {rule, warning?, rules}; warning when account is neither a declared [scm.*] account nor github/gitlab (the rule is written but may never match). Comments survive; the result is validated before it is written, under the config file's lock. 400 a bad rule or a config that would not validate; 501 without rupu cp serve.
GET/api/repos/trackedThe local repo registry (~/.rupu/repos/).Rows {id, repo, preferred_path, known_paths, default_branch?, last_seen_at}; id is the record key DELETE takes, repo is <platform>:<owner>/<repo>.
POST/api/repos/trackedTrack a checkout as a copy of a repo (rupu repos attach).Body {repo, path}: repo in run-target syntax (github:owner/repo; a PR or GitHub/GitLab issue target names its repo), path absolute. The checkout's origin must point at repo; its origin url and default branch are recorded. → the tracked repo row. 400 a relative or missing path, no git checkout, or a checkout of another repo; 501 without rupu cp serve.
POST/api/repos/tracked/preferTrack a checkout and make it the repo's preferred path (rupu repos prefer).As POST /api/repos/tracked.
DELETE/api/repos/tracked/:idForget a tracked repo, or one path (rupu repos forget).path (query) forgets only that path; forgetting the last path forgets the repo. → {repo, path?, remaining}, remaining the record left or null. 404 unknown id or untracked path; 501 without rupu cp serve.
GET/api/auth/statusCredential state per account (rupu auth status): the built-in vendors, then every declared [providers.*] / [scm.*] account.Rows {account, kind, api_key, sso, state}; sso is {expires_at, expired, refreshable, summary} or null, state as above. Read from the global config and this host's credential store.

Customers

Writes need a cp serve deployment and answer 501 otherwise. The config layer and the rupu customer CLI are in Customers.

RouteSuccessErrors
GET /api/customers?archived=1&range=7d|30d|all200, rows: customer + tint + rollup + default_account + layer_error (default range 30d; archived only with archived=1)400 bad range; 500 an unreadable assignment (names the workspace)
POST /api/customers {slug, name, notes?, contact?, color?}201, the customer400 invalid slug / color / empty name, or the reserved slug none (the filter's "no customer"); 409 slug exists; 501
GET /api/customers/:slug?range=200, {customer, rollup, projects[], default_account, layer_error, config_path}404 unknown; 400 bad range; 500 unreadable assignment
PATCH /api/customers/:slug200; absent fields stay, "" clears notes / contact / color400; 404; 501
POST /api/customers/:slug/archive, …/unarchive200, the customer404; 501
DELETE /api/customers/:slug204409 {error, projects: [{ws_id, path}]} while projects are assigned; 404; 501
PUT /api/customers/:slug/projects/:ws_id200, the project's row (any earlier assignment is replaced)404 unknown customer / project; 409 customer archived; 400 malformed id; 501
DELETE /api/customers/:slug/projects/:ws_id204404 when the project is not assigned to :slug; 501
GET /api/config?customer=<slug>200, effective config + provenance + customer_lock, raw_customer, customer, layer_error (+ layer_error_kept: global_customer when only ?project='s own layer is broken and its customer's layer resolves, else global)400 with ?project= (mutually exclusive); 404; 500 a dangling customer on ?project=
PUT /api/config/customer/:slug200 {ok: true}400 the layer breaks the merged config, or sets a key the GLOBAL [policy].lock enforces; 404; 501
POST /api/launch/preview200, see below400 / 404 / 409, see below

A customer's tint is its color for both themes when set, else a light/dark pair derived from the slug, so clients never invent colors. default_account is {account, locked_by, inherited}: the provider account the customer's runs default to over global + its layer (inherited: true when the value is the global one); null when nothing sets one or the layer does not resolve (layer_error says why on the detail). The detail's projects[] are the customer's current projects; each row's usage, run_count and last_active cover only this customer's work in that project over the same range as rollup. Rollups are computed in one pass over the run store: standalone agent runs and session turns add spend and activity, never run_count, and each run is priced with the pricing of the customer it is attributed to. A customer whose layer does not resolve is still listed, with default_account: null and layer_error, and its work is priced at the global rates — every usage summary that includes such work carries pricing_error naming the customer.

?customer=<slug>|none

<slug> selects one customer's work; none selects work with no customer. A malformed slug is 400. A slug no customer carries is not an error — it matches nothing. A run records the customer it ran under at launch, tri-state on disk: a slug, an explicit null (recorded no customer, even if the project has been assigned since) or no key at all (a legacy record, attributed to the project's current assignment and marked customer_derived: true). Findings and projects record no customer — they follow the project's current assignment.

EndpointWhat the filter keys on
GET /api/runs, /api/runs/workflows, /api/runs/agentsthe run's attribution (recorded, else derived); applied BEFORE paging
GET /api/sessionsthe session's recorded customer, else derived
GET /api/findingsthe project's CURRENT assignment (the summary counts only the kept findings)
GET /api/projectsthe project's CURRENT assignment
GET /api/usage, /usage/timeline, /usage/runsthe attribution of each LOCAL source (runs, standalone agent runs, session turns), priced per customer
GET /api/usage/outliersthe same LOCAL sources, as both the candidates and the per-workflow baselines; a bad slug is 400
GET /api/dashboardthe LOCAL host's runs by attribution, findings_open by the customer's projects, autoflow cycles by the projects of the repos they touched; the fleet counts are not run-scoped and stay unfiltered

Remote hosts

A remote host's run and session rows are filtered on the coordinator by their customer key, reading page after page past the peer's clamp. If any row lacks the key (a peer older than customers, a mirror holding a worker's legacy runs, or rows the peer could not attribute), the host "can't report a customer for every run": a single-host request (?host=<id>) answers 501, and the fan-out skips the host and names it in the X-Rupu-Hosts-Without-Customer response header (comma-separated host ids). It is never counted as zero. Aggregates (/api/usage, /api/dashboard) are local-only under a filter: ?host=<remote>&customer=… is 501, and the fan-out reports each remote host unavailable rather than counting it. A mirrored legacy run is named in a hosts_without_customer array on /api/usage, /api/dashboard and each customer row's rollup.

An unreadable project assignment (workspaces/<id>.customer) fails closed: every filtered request and every count, rollup or price answers 500 naming the workspace and the file to repair. An unfiltered run / session list does not fail: the affected rows omit the customer / customer_derived keys and a warning goes to the server log.

Launch preview

POST /api/launch/preview says what a launch would authenticate as, before it runs. Body: exactly one of workflow / agent (400 otherwise), plus the launcher's working_dir or scope_kind / scope_id (mutually exclusive, as on the launch routes; with neither, the server's cwd) and an echoed host. It resolves exactly like a launch: a project assigned to a customer that no longer exists, or a config that does not load, is 409, as is an agent file that does not load (one malformed file fails the loader for every agent). 404 for an unknown workflow, or an unknown agent on an agent launch. A name containing /, \ or .. (or empty) is 400 before any file is read.

Response {customer, accounts, warnings, host?}: customer is the row reference (slug, name, tint, archived) or null; accounts are {role: provider|fallback|scm, account, kind, auth_mode, agents[], source} entries, source saying where each choice came from ("agent frontmatter", "customer default", "global default · locked", "rule owner = acme-corp", …); warnings are resolver warnings, a workflow step's agent that was not found, an origin that is not a github.com / gitlab.com remote, an ambiguous or unresolvable SCM account and, for a non-local host, that the preview resolves against the machine running the control plane. The SCM candidates are only the accounts the config declares for the repo's platform that have a stored credential — the same set rupu scm accounts shows; provider credentials are not probed here.

Agentiflows

Local host only, except the message space (Messages), which takes host. The writes answer a remote ?host= with a 501. The concept is on Agentiflows.

MethodPathPurposeNotes
GET/api/agentiflowsAll agentiflow runs, newest first ({rows}).
GET/api/agentiflows/:idOne run: record, definition, budget, events, units, lead transcripts (AgentiflowDetail).404 unknown or malformed id.
GET/api/agentiflows/:id/messagesThe run's message space (MessagesView).See Messages.
GET/api/agentiflows/:id/childrenEvery child the flow launched, in the shape of GET /api/runs/:id/children.Local only.
GET/api/agentiflows/:id/boardThe run's board: BoardView (below).Local only (?host= is a 501). 404 unknown id. A run with no board answers empty lists.
GET/api/agentiflows/:id/generated/:handleThe workflow definition the lead wrote and dispatched as child handle: {handle, name, file_name, yaml}.Local only. 404 when handle is no direct child of this run (the route reads only the flow's own children ledger, so a workflow a deeper child generated is a 404), is not a generated workflow (generated is false on its ChildDto), or its file is gone; 403 when its recorded file is not a regular file under the run's own generated/ directory (a generated/ that is a symlink or not a directory counts).
POST/api/agentiflows/:id/steerMessage the lead, or stop the run.Body SteerRequest {message, now?, stop?}. Without stop: the alias of POST /api/agentiflows/:id/messages {to: "lead", body: message, urgent: now}, with its answers (202 {id, delivery}). With stop: true: queues a graceful stop → 200 {queued: true}; 400 empty message; 404 unknown run; 409 when nothing would read it — the run isn't running, or its recorded coordinator process is gone (a dead coordinator leaves running on disk until the reaper closes it); 501 without rupu cp serve.
POST/api/agentiflowsLaunch a run detached, as rupu agentiflow run --detach does.Body {definition, scope_kind?, scope_id?, working_dir?}. The run starts in the directory scope_kind (global|project, with scope_id the project's workspace id) picks, or the absolute working_dir, or the control plane's own; the two are exclusive. The id is minted before the spawn and the answer waits for the run to record itself: 202 {id, status: "started"}, or 202 {id, status: "pending", log} when it is still starting after 30 s. Before anything starts, the definition is looked up from that directory (project first, then global) and checked as the CLI checks it: 404 not found; 400 a file that does not parse, an unknown engagement profile, a validation rule, a bad working_dir or scope_kind; 404 a scope that doesn't hold it. A run whose process exits before it starts (a lead agent that does not load, no credential) is a 400 carrying its log, and leaves no run directory. ?host=<remote> is a 501 (local only); 501 without rupu cp serve.
POST/api/agentiflows/:id/stopStop a running agentiflow.Body {now?} (or none; a body that isn't one, e.g. {"now": "true"}, is a 400). Without now, the graceful stop steer {stop: true} queues, answered as steer answers it: 200 {queued: true}; 409 when nothing would read it (see steer). With now: true, the hard stop rupu agentiflow stop --now runs: the coordinator and every unit still running are signalled (SIGKILL after a grace) and the run is recorded failed, answered once done with 200 {stopped: true, status, stop_reason} (operator_stop:now); a running record whose coordinator died is closed out the same way. 404 unknown run; 409 a run that is not running; ?host=<remote> 501; 501 without rupu cp serve.

Rounds and children are read with GET /api/agentiflows/:id. The round events in events carry, beyond the round number and counts: started_at; decision (the round's own last assistant reply, cut on a word boundary at 600 characters with … appended; omitted when the round produced no reply text, so a failed round never shows an earlier round's decision); goals ([{id, met, current}] as the round started, so the state after round N is round N+1's goals, and after the last round the record's goals); and warnings (omitted when empty). Each units row carries round and generated like a child. A run recorded before these fields shows less, never a guess: no decision, no goals, and units without a round are placed by inference and marked so by clients.

Board

BoardView is the wire shape of GET /api/agentiflows/:id/board, GET /api/runs/:id/board, the hidden rupu __board <id> and the MCP tools agentiflows.board / runs.board:

{
  "claims": [{"key": "…", "key_known": true, "owner": "heron#2", "acquired_at": "…",
              "lease_expires_at": "…", "ended_at": null, "state": "held"}],
  "directives": [{"id": "…", "author": "lead", "ts": "…", "body": "…", "addressed_to": "role:recon",
                  "retracted": false}],
  "journal": true
}

claims is every acquisition the space's claim journal (board/claims.jsonl, see Messaging) recorded, released and expired ones included. state is held, released or expired; ended_at is when a released or expired claim ended. directives keeps retracted ones, with retracted and, when the retraction recorded a time, retracted_at; addressed_to and retracted_at are omitted (not null) when absent — a directive to everyone has no addressed_to. journal is false for a space written before the journal: claims then holds only the claims live now, each held with key_known: false (its raw key was never recorded, so key is the claim file's stem). An empty or absent board is empty lists, not an error.

GET /api/runs/:id/board serves a workflow run or standalone run (a run_… id; anything else is a 404), with host. A remote host is asked over HTTP when its /api/host/info lists the run.board feature (otherwise a 501, "upgrade rupu there"), over SSH through rupu __board <id> after the same rupu __features gate as run.messages, and a tunnel or bucket host is a 501.

Agentiflow definitions

The <name>.yaml files rupu agentiflow run <name> loads: the global layer (<RUPU_HOME>/agentiflows/, beside the run directories, which are never read as definitions) and each registered project's .rupu/agentiflows/. A project definition shadows a same-named global one, as it does for the CLI. The scope_kind/scope_id selector works as it does for workflows. Local only: every route answers ?host=<remote> with a 501.

MethodPathPurposeNotes
GET/api/agentiflow-defsEvery definition.Rows {name, scope, scope_kind, scope_id, description, lead, engagement_profiles, error}, sorted by name then scope. A file that does not parse is still listed, with error.
GET/api/agentiflow-defs/:nameOne definition.Query scope_kind, scope_id; without them, project first. {name, def, yaml, error, scope, scope_kind, scope_id}: def is the parsed definition, or null with error when it does not parse. 404 not found in the layer asked for.
POST/api/agentiflow-defsCreate a definition in the global layer.Body {raw}, named by its name:. Answers as GET. 400 a file that does not parse or a bad name (validate included: it is another route); 409 the name exists in any layer; 501 without rupu cp serve.
PUT/api/agentiflow-defs/:nameOverwrite a definition.Query scope_kind, scope_id; body {raw}, whose name: must equal :name (400). With a selector, that layer's existing file (404 otherwise); without, the project-first match, or a new global file. Answers as GET. 501 without rupu cp serve.
DELETE/api/agentiflow-defs/:nameDelete a definition file.Query scope_kind, scope_id; without them, the project-first match. {deleted, scope, scope_kind}; 404 nothing matches; 501 without rupu cp serve.
POST/api/agentiflow-defs/validateCheck a definition as agentiflow run does, writing nothing.Body {raw, scope_id?}: the file parses, its engagement profiles resolve (built-ins, <RUPU_HOME>/profiles and, with scope_id, that project's .rupu/profiles) and it validates against them. {ok: true, name, lead, engagement_profiles}, or a 400 naming the first problem; 404 an unknown scope_id. A definition named validate can't be reached by GET /api/agentiflow-defs/:name.

Node tunnel

GET /api/node/connect upgrades to a WebSocket for rupu node peers that dial home (a tunnel host). It is host-internal and sits outside the bearer check; it authenticates with the node's own enrollment token:

  1. Enroll the node with POST /api/hosts/node, which returns a one-time token and the rupu node --cp-url ws://<cp>/api/node/connect --token … --node-id … command to run on the node. Only the token's hash is stored.
  2. Within 10 s of connecting, the node sends a Hello frame with its node id, token, capabilities and version. An unknown node or bad token closes the socket.
  3. The CP answers Welcome with its own capabilities, then pings every 30 s. The node streams run artifacts (transcripts, coverage) and finished-run notices back over the same socket, and serves artifact pulls on request.

A newer connection for the same node replaces an older one. Frames are JSON text messages; the shapes are rupu-cp's node::protocol::Frame.

Health

MethodPathPurposeNotes
GET/healthzLiveness: 200 ok (plain text).Never requires the token. Response headers x-rupu-pid and x-rupu-version identify the serving process; a second cp serve that loses the bind race uses them to name the PID holding the port.
curl -si http://127.0.0.1:7878/healthz
# HTTP/1.1 200 OK
# x-rupu-pid: 41207
# x-rupu-version: 0.82.0
#
# ok