# HTTP API

The daemon's local API and its event stream.

The daemon's local API, used by the CLI, the pane, and the screening page. Schemas: `contracts/src/api.ts`.

## The daemon

The daemon listens on `http://127.0.0.1:4317` (or the port `rapid serve --port` gives it), on 127.0.0.1 only. Routes live under `/api/`; everything else is the screening page.

Every JSON answer is one of:

```json
{ "ok": true, "data": { } }
{ "ok": false, "error": { "code": "not_found", "message": "no job nope" } }
```

Error codes are `bad_request`, `not_found`, `conflict`, and `internal`. A `POST` must send `content-type: application/json`, or it gets `bad_request`.

### Routes

| Method and path | Body | `data` |
|---|---|---|
| `GET /api/health` | | `{ version, pid }`: the serving daemon's process id |
| `GET /api/jobs` | | `{ items: Job[] }` |
| `GET /api/projects` | | `Projects`: `{ roots: { path, exists, projects: { path, title, gone, jobs: Job[] }[] }[], elsewhere: Job[], missing: Job[] }`: each folder `config.json` names (default `~/rapid`) with the folders directly inside it as projects and their jobs, newest first; `elsewhere`, the jobs under none of them; `missing`, the jobs whose project folder is gone |
| `POST /api/projects/roots` | `{ path }` | `{ path, changed, roots }`: adds a folder to find projects in, as `rapid projects add` does; it must exist |
| `POST /api/projects/roots/remove` | `{ path }` | `{ path, changed, roots }`: stops finding projects in a folder, which is left as it is |
| `POST /api/projects/videos` | `{ root, brief }` | `{ job, project }`: starts a video from the brief in a new project folder inside `root`, which must be one of the folders, in the default style and format with no stops |
| `POST /api/jobs` | `CreateJob` | `{ job: Job }` |
| `POST /api/videos` | `CreateFilm` | `{ job: Job }` |
| `GET /api/jobs/:job` | | `Graph`: `{ job, nodes, edges, artifacts }` |
| `GET /api/jobs/:job/format` | | `JobFormat`: `{ name, width, height, fps }`, the job's format as its video finds it; 404 when it names none or none is found |
| `GET /api/jobs/:job/choices` | | `{ items: Choice[] }`: what the job's video chose without a person, oldest first |
| `POST /api/jobs/:job/nodes` | `AddNodes` | `{ nodes: Node[] }` |
| `POST /api/jobs/:job/cancel` | | `{ job: Job }` |
| `POST /api/videos/:job/replan` | `{ note }` (1 to 4000) | `{ job: Job }`: asks the storyboarder again; the new storyboard waits for approval when the video has stops |
| `POST /api/videos/:job/revise` | `FilmRevision`: `{ note, scene?, t?, t_end?, severity? }` | `{ job, revision: { route, scene?, round?, mark_id? } }`: routes a note on a video, done or not (`mark`: a scene and a time; `redo`: a scene; `replan`: the video), then runs it to the video again with no stops |
| `POST /api/nodes/:node/approve` | | `{ node: Node }` |
| `POST /api/nodes/:node/redo` | `{ note? }` (up to 4000) | `{ node: Node }` |
| `GET /api/lanes` | | `{ items: LaneStatus[] }` |
| `GET /api/scenes/:scene/marks` | | `{ items: Mark[] }` |
| `POST /api/marks` | `NewMark` | `{ mark: Mark }` |
| `GET /api/artifacts/:artifact` | | the artifact's bytes: posters, sheets, MP4s; `Range` supported |
| `GET /api/events` | | the event stream, below |

### Bodies

`CreateFilm`, what `rapid video` sends:

| Field | Type | Default |
|---|---|---|
| `project` | string | required; an absolute folder |
| `style` | string | a name, a pinned package, or a folder; this or `look` |
| `look` | string | a draft style folder from `rapid style extract`, which the job finishes, checks, and pins first; this or `style` |
| `format` | string | `"reel"` |
| `review` | `"agent"` or `"none"` | `"agent"` |
| `rounds` | integer 1–10 | `4` |
| `keys` | `"agent"`, `"person"`, or `"off"` | `"agent"`; with `review: "none"`, `agent` means `off` |
| `director` | `"agent"` or `"none"` | the director agent; none with `review: "none"` |
| `stops` | `"all"`, `"storyboard"`, or `"none"` | `"all"` |
| `rebuild` | boolean | `false` |
| `music` | string | none; a track to cut the video to, an absolute path or one in the project |

`CreateJob`, what `rapid new` sends: `title` (1–200, required), `brief` (up to 20000, default `""`), `project` (required), `style` and `format` (string or null, default null).

`AddNodes` is `{ nodes: [...] }`. Each new node has a `key` (the caller's name for it, so later nodes in the same request can name it in `needs`), a `type`, a `lane`, a `label`, and optionally a `group`, `needs` (keys or ids of the job's earlier nodes), and a `spec`. A spec with `argv` runs a command, in `cwd` relative to the project (default the project itself); a spec with a `prompt` runs an agent session, with optional `agent`, `model`, and `allowed_tools`.

```json
{
  "nodes": [
    { "key": "a", "type": "custom", "lane": "encode", "label": "transcode", "spec": { "argv": ["ffmpeg", "-i", "in.mov", "out.mp4"] } },
    { "key": "b", "type": "approval", "lane": "cpu", "label": "look at it", "needs": ["a"] }
  ]
}
```

`NewMark` is a [Mark](/docs/review#mark) without `id` and `created_at`, with an optional `job_id`.

### Shapes

`Job`: `id`, `title`, `brief`, `project`, `style` (or null), `format` (or null), `state` (`running`, `needs_you`, `done`, `failed`, `cancelled`), `error` (why the job failed when no node says, or null), `created_at`.

`Node`:

| Field | Type |
|---|---|
| `id`, `job_id` | string |
| `type` | `storyboard`, `scene`, `check`, `review`, `blender`, `render`, `scan`, `listen`, `mix`, `mux`, `phone`, `approval`, `extract`, `custom` |
| `lane` | `think`, `gpu`, `web`, `encode`, `cpu` |
| `label` | string, 1–120 |
| `group` | string or null: the scene it belongs to |
| `state` | `waiting`, `queued`, `running`, `done`, `failed`, `stale`, `needs_you`, `cancelled` |
| `fingerprint` | sha256 or null |
| `needs`, `outputs` | arrays of ids |
| `poster` | artifact id or null |
| `attempt` | integer |
| `progress` | 0–1 or null |
| `error` | string or null |
| `timings` | `{ queued_at, started_at, ended_at, wait_ms, run_ms }` |
| `spec` | object: what the worker runs |

`Edge`: `{ from, to }`.

`Artifact`: `id`, `node_id`, `kind` (`storyboard`, `scene_code`, `frames`, `still`, `sheet`, `boxes`, `cues`, `review`, `clip`, `wav`, `mp4`, `style`, `json`, `log`), `path` (absolute or relative to the project), `sha256`, `bytes`, `created_at`.

`LaneStatus`: `{ lane, slots, running: string[], queued: string[] }`.

### The event stream

`GET /api/events` is a Server-Sent Events stream. It opens with `retry: 2000`; each event is an `id:` line with its sequence number and a `data:` line with the event as JSON, on the default event type; comment lines are heartbeats.

```text
retry: 2000

id: 1
data: {"seq":1,"at":"2026-10-28T20:35:41.591Z","job_id":"j_819b0fb4","type":"job.created","data":{"job":{...}}}

id: 2
data: {"seq":2,"at":"2026-10-28T20:35:41.594Z","job_id":"j_819b0fb4","type":"node.created","data":{"node":{...},"edges":[]}}
```

| Query | Means |
|---|---|
| `after=<seq>` | resume after this sequence number; the `Last-Event-ID` header does the same |
| `job=<id>` | only this job's events |
| `once=1` | send what's there, then end |

Every event has `seq` (strictly increasing across the daemon), `at` (an ISO time), `job_id`, `type`, and `data`:

| Type | `data` |
|---|---|
| `job.created` | `{ job }` |
| `job.updated` | `{ job }` |
| `node.created` | `{ node, edges }` |
| `node.updated` | `{ node }` |
| `node.output` | `{ node_id, artifact }` |
| `node.log` | `{ node_id, stream, line }`; `stream` is `stdout`, `stderr`, or `agent` |
| `lane.updated` | `{ lane }`; `job_id` may be null |
| `mark.created` | `{ mark }`; `job_id` may be null |
| `review.done` | `{ node_id, review }` |

`rapid events --json` prints the same events, one per line.
