Relay Relay / Docs Open the board
Menu

BUILD WITH RELAY

REST API reference

Relay exposes a small JSON REST API scoped to a single board. It is the same entry point the ./relay CLI uses, so anything the CLI does you can do directly.

Base URL & authentication

All endpoints live under /api and require a board API key — except GET /api/version and the two scaffold endpoints (GET /api/scaffold, GET /api/scaffold/*path), which are intentionally public:

Authorization: Bearer <board API key>

Mint a key at /board/:slug/settings → API keys (each board has a single key; regenerating replaces it). Every write is attributed to "Relay AI" (the agent actor). Requests without a valid key get 401 unauthorized.

Conventions

  • JSON in, JSON out. Send Content-Type: application/json; responses are JSON.
  • Card refs. Cards are addressed by their human ref, e.g. RLY-12 (the board key plus the card number). Wherever a path says :ref, use that.
  • Errors use a consistent envelope:
{ "error": { "code": "not_found", "message": "Not found" } }
HTTP code when
401 unauthorized missing/invalid Authorization bearer key
402 plan_paused the board's owner's plan is paused; reads and writes are refused until it is paid
404 not_found no card/stage matches the ref/id
400 invalid malformed body or unusable field values
422 not_gated approve/reject on a card whose stage is not an approval gate
422 missing_note reject without a non-empty note
422 invalid_target reject to a stage that isn't a valid main-lane target
422 unknown_refs depends_on names a ref this board does not have (nothing is written)
422 dependency_cycle depends_on would close a dependency cycle; the message names the path

Card & stage shape

Cards are serialized by CardJSON. The base shape (returned in lists and as the data of every card response):

{
  "id": 42,
  "ref": "RLY-12",
  "title": "Wire up the API docs",
  "tag": "feature",
  "status": "working",
  "progress": 40,
  "branch": "rly-12-api-docs",
  "pr_url": "https://github.com/acme/relay/pull/7",
  "stage_id": 5,
  "owners": [{ "type": "agent", "name": "Relay AI" }],
  "active_owner": "agent",
  "rejection": null
}

owners entries are either { "type": "agent", "name": "Relay AI" } or { "type": "user", "id": 3, "name": "Ada" }. rejection, when present, is { "note", "from_stage", "to_stage", "rejected_by", "rejected_at" }.

The single-card responses (show, and every write that returns a card) add the heavy fields:

{
  "description": "…markdown…",
  "acceptance_criteria": "…markdown…",
  "plan": "…markdown…",
  "spec": "…markdown…",
  "depends_on": [{ "ref": "RLY-13", "title": "Ship the migration", "satisfied": false }],
  "blocks": [{ "ref": "RLY-20", "title": "Backfill the index" }],
  "timeline": [
    { "kind": "comment", "body": "on it", "author": { "type": "agent", "name": "Relay AI" }, "inserted_at": "…" },
    { "kind": "activity", "type": "moved", "meta": { "from_stage": "Spec", "to_stage": "Code" }, "author": { "type": "agent", "name": "Relay AI" }, "inserted_at": "…" },
    { "kind": "activity", "type": "action", "text": "implement: starting", "meta": {}, "author": { "type": "agent", "name": "Relay AI" }, "inserted_at": "…" }
  ]
}

depends_on is the card's blockers (satisfied is true once that blocker has reached a top-level Done column); blocks is the reverse edge. Both are single-card only — the list shape omits them. A card with an unsatisfied blocker is never pulled by a flow, and GET /api/cards/:ref/diagnosis reports the blocked_by_dependencies verdict. Work already in flight is unaffected: a running run is not cancelled by a dependency added mid-run, and a card sent back for changes re-enters its flow as usual.

A stage (returned inside GET /api/board's stages and by every /api/stages route):

{
  "id": 9, "name": "Code:Review", "display_name": "Code · Review",
  "category": "in_progress", "type": "review", "ai_enabled": false,
  "position": 12, "wip_limit": null, "parent_id": 8,
  "description": null, "collapsed_by_default": false, "reject_to_stage_id": null
}

type is one of queue | work | planning | review | done; category one of unstarted | planning | in_progress | complete. A substage (parent_id set) is a Review or Done lane of its main stage; its name is the composite "<parent>:Review" and its display_name the human "<parent> · Review" (a main stage's display_name is its name).

Every /api/stages response (not GET /api/board's stages) also carries a read-only "problem": null, or — on the stage of an enabled flow paused by a broken board shape — the same problem object GET /api/flows/:key returns (see GET /api/flows below).


Endpoints

GET /api/board

The whole board: its identity, stages, and cards (base shape).

curl -H "Authorization: Bearer $RELAY_KEY" https://relay.example/api/board
{
  "board": { "id": 1, "name": "My board", "key": "RLY" },
  "stages": [ { "id": 1, "name": "Backlog", "…": "…" } ],
  "cards":  [ { "id": 42, "ref": "RLY-12", "…": "…" } ]
}

GET /api/cards

All cards on the board (base shape).

{ "data": [ { "id": 42, "ref": "RLY-12", "…": "…" } ] }
param effect
(none) The board's cards, top-level Done stages excluded.
include_done=1 Include the top-level Done stages. Ignored when q is present.
q=<text> Search by ref or title. A ref or bare number (RLY-12, 12) is an exact hit ranked first, then title matches in board order; every whitespace-separated word must appear in the title, in any order; case-insensitive; % and _ are literals, not wildcards. Done cards are always in scope. A present-but-blank q returns [], never the whole board.
limit=<n> Cap the number of results (the exact-ref hit counts toward it).
archived=1 Widen the corpus so archived cards join the results.

./relay search "words" is the CLI wrapper for q.

curl -H "Authorization: Bearer $RELAY_KEY" "https://relay.example/api/cards?q=login&limit=10"

POST /api/cards

Create a card. Optional stage (a stage id; defaults to the board's first stage). Accepts title, description, acceptance_criteria, spec, tag, branch, plan, pr_url, and depends_on (a list of card refs this card is blocked by). Returns 201 with the single-card shape. A depends_on that names an unknown ref is refused with unknown_refs before the card is created, so a refused create leaves no card behind.

curl -X POST -H "Authorization: Bearer $RELAY_KEY" -H "Content-Type: application/json" \
  -d '{"title":"New card","stage":5}' https://relay.example/api/cards
{ "data": { "id": 43, "ref": "RLY-13", "title": "New card", "…": "…" } }

GET /api/cards/:ref

One card, single-card shape (with description, acceptance_criteria, plan, spec, timeline).

curl -H "Authorization: Bearer $RELAY_KEY" https://relay.example/api/cards/RLY-12

PATCH /api/cards/:ref

Update a card. Any of title, description, acceptance_criteria, spec, tag, branch, plan, pr_url; plus status (with optional progress); plus owners (a list of "agent" or "user:<id>"); plus depends_on, a list of card refs that replaces the card's blocker set ([] clears it). Returns the single-card shape. depends_on is refused with unknown_refs or dependency_cycle and writes nothing.

curl -X PATCH -H "Authorization: Bearer $RELAY_KEY" -H "Content-Type: application/json" \
  -d '{"status":"working","progress":40}' https://relay.example/api/cards/RLY-12

ai_result is also set here (this is what ./relay result calls), and it is the one field whose shape is enforced. Three optional keys, nothing else:

{ "ai_result": {
    "summary": "- **One door** for everyone…",
    "changes": ["Adds a summary to the card drawer"],
    "screens": [{ "url": "/attachments/135e5539-…", "caption": "Sign in" }]
} }

summary is a markdown string, changes a list of strings, screens a list of objects with url (required) and an optional caption. A screen's url is the image, not the page it shows — upload the file to POST /api/cards/:ref/attachments (./relay attach) and use the /attachments/<id> path it returns. Any other key, at either level, is refused with 422 invalid_ai_result and a message naming the key you used and the keys that exist; nothing is written.

mockups is also set here (this is what ./relay mockups calls). It replaces the card's whole list; [] or null clears it:

{ "mockups": [{ "url": "/attachments/135e5539-…", "caption": "Empty state" }] }

Each entry takes only url (required) and caption (optional string). The url must be the /attachments/<id> path of an HTML or image attachment on this same card — upload it first with POST /api/cards/:ref/attachments (content_type text/html, image/png, image/jpeg, image/webp or image/gif; ./relay mockups takes .html .png .jpg .jpeg .webp .gif files). Anything else is refused with 422 invalid_mockups naming the entry's index; nothing is written. GET /api/cards/:ref returns the list as mockups ([] when none). HTML mockups are served under a sandbox CSP: inline JS/CSS and data: assets, plus Google Fonts as the only network exception — stylesheets from https://fonts.googleapis.com and font files from https://fonts.gstatic.com. Every other request (remote scripts and images, fetch/XHR/WebSocket/EventSource, frames, form submission) is blocked. Opening a mockup that links Google Fonts sends a request to Google, which sees the viewer's IP and the font URL (mockups are served with Referrer-Policy: no-referrer and run in an opaque origin, so no Relay host, path or cookie goes with it).

POST /api/cards/:ref/attachments

Upload a file to the card — an image (png/jpeg/webp/gif) or, for a mockup, a self-contained text/html page (5 MB cap). Requires filename, content_type and data_base64. Returns 201 with {"data": {"id": …, "url": "/attachments/<id>", "markdown": "![…](/attachments/<id>)"}} — that url is what a screenshot's screens[].url should be.

GET /api/attachments/:id

Download an attachment's raw bytes with the board API key — how ./relay mockups REF --pull reads a card's mockups back (it names each file by the returned Content-Type, so a PNG mockup lands as NN-<slug>.png). :id is the last segment of the /attachments/<id> url the upload returned (and that a card's mockups[].url / screens[].url carry). Returns 200 with the stored bytes byte-for-byte and the stored Content-Type (HTML keeps its sandbox Content-Security-Policy and X-Content-Type-Options: nosniff), plus Content-Disposition: attachment; filename="…" and Cache-Control: private, …. Scoped to the key's board: an unknown or malformed id, or an attachment on another board, is 404 not_found — never 403, so an id on another board is not revealed. Serves any attachment on the board (screenshots too), not only mockups.

curl -H "Authorization: Bearer $RELAY_KEY" -o mock.html https://relay.example/api/attachments/135e5539-…

Stages

Any board key may manage the board's stages. Guard rails live in the domain, so Board Settings refuses the same things with the same sentence. A stage refusal carries extra keys in the error object where noted:

HTTP code when
404 not_found the path :id is unknown, not an integer, or another board's stage
409 last_stage deleting the board's only main stage
409 not_empty the stage (or a substage) holds cards, archived included — a stage delete adds "live": n, "archived": m; a lane disable carries no counts
409 holds_flow PATCH changes the type of a stage that holds a flow to one that isn't work/planning — adds "flow": key
409 public_intake it is the board's public intake stage
422 invalid_anchor before / after doesn't name another main stage on this board (unknown, foreign or non-integer anchors included)
422 not_a_main_stage the route needs a main stage and :id is a substage
422 invalid the stage itself is invalid (blank name, unknown type, bad reject_to_stage_id, …)
422 invalid_request the body is malformed — the message says how
{ "error": { "code": "not_empty", "message": "That stage still holds 0 live and 1 archived card(s) — move them out first.", "live": 0, "archived": 1 } }

GET /api/stages

The board's stages, each main stage followed by its substages (Review, then Done). A stage whose enabled flow is paused by a broken board shape carries that flow's problem; every other stage has "problem": null.

{ "data": [ { "id": 1, "name": "Backlog", "…": "…" } ] }

POST /api/stages

Create a main stage. Body: name, and either category (appends at the end of that category) or ONE of before / after (a main-stage id — the new stage lands beside it and adopts its category); optionally type (defaults from the category), description, wip_limit, collapsed_by_default. 201 {"data": stage}. Both anchors → 422 invalid_request ("send before or after, not both").

curl -X POST -H "Authorization: Bearer $RELAY_KEY" -H "Content-Type: application/json" \
  -d '{"name":"Triage","after":3,"type":"queue"}' https://relay.example/api/stages

PATCH /api/stages/:id

Configure a stage: any of name, description, type, wip_limit, collapsed_by_default, reject_to_stage_id (a main stage). "wip_limit": null and "reject_to_stage_id": null clear. A type change re-snaps the statuses of the cards in it. Renaming a main stage renames its substages. 200 {"data": stage}. No recognised field → 422 invalid_request.

ai_enabled in a stage response is read-only, derived from flows (a stage is AI-enabled iff a flow is on it); sending it to POST or PATCH — any value — is 422 invalid_request and nothing is written.

POST /api/stages/:id/place

Move a main stage beside another: exactly one of before / after (a main-stage id). The stage adopts the anchor's category (its type is unchanged). 200 {"data": stage}. Neither or both → 422 invalid_request ("send exactly one of before or after").

PUT /api/stages/:id/substages/:lane

Enable the review or done lane on a main stage — idempotent. 200 {"data": substage}. Any other :lane → 422 invalid_request ("lane must be one of: review, done").

DELETE /api/stages/:id/substages/:lane

Disable a lane. 200 {"data": {"lane": "review", "disabled": true}} — disabled: false when the lane wasn't enabled. Refused with not_empty or public_intake.

DELETE /api/stages/:id

Delete a main stage and its substages. 200 {"data": stage} (the deleted stage). Refused, in order, with last_stage, not_empty (with counts), public_intake. The flow on the stage, enabled or not, is deleted with it (its runs keep their flow_key); another stage's reject-to pointing here is cleared.

GET /api/flows

GET /api/flows (every flow) and GET /api/flows/:key (one) return each flow as its canonical document, plus two read-only blocks: derived (pulls_from / lands_on, the stage names worked out from board order) and problem. PUT /api/flows/:key returns the same shape and accepts a pulled document back unchanged — both read-only keys are ignored on push.

problem is null when the flow's board shape works. When it doesn't — nothing before the flow's stage, a Review column or another flow's working stage right before it, or nothing after it — it explains why, in words the API owns (render them verbatim):

{
  "flow_key": "deploy",
  "kind": "upstream_working",
  "what": "Flow **deploy** pulls from Code, which is flow **code**'s working stage.",
  "why": "…",
  "fixes": [
    { "action": "enable_lane", "label": "Turn on Code · Done" },
    { "action": "insert_queue_stage", "label": "Insert a queue stage between Code and Deploy" }
  ],
  "columns": [ { "stage_id": 8, "name": "Code", "type": "work", "mark": "offending" } ]
}

kind is one of no_upstream | upstream_review | upstream_working | no_downstream; a fix's action one of enable_lane | insert_queue_stage | add_stage_after; a column's mark is self, offending or null. An enabled flow with a problem is paused: no new runs start until the board is fixed (runs already going finish and land). A disabled flow still reports its problem but is not paused.

POST /api/cards/:ref/move

Move a card. stage is a stage id or name; position is 1-based (omit to append). Returns the single-card shape.

curl -X POST -H "Authorization: Bearer $RELAY_KEY" -H "Content-Type: application/json" \
  -d '{"stage":"Plan","position":1}' https://relay.example/api/cards/RLY-12/move

POST /api/cards/:ref/comments

Add an agent comment. Requires body. Returns 201 with the new timeline entry:

{ "data": { "kind": "comment", "body": "on it", "author": { "type": "agent", "name": "Relay AI" }, "inserted_at": "…" } }

POST /api/cards/:ref/needs-input

Flag the card as needing human input. Requires question. Returns the single-card shape.

curl -X POST -H "Authorization: Bearer $RELAY_KEY" -H "Content-Type: application/json" \
  -d '{"question":"Which auth provider?"}' https://relay.example/api/cards/RLY-12/needs-input

POST /api/cards/:ref/approve

Approve a card sitting on an approval-gate stage (advances it). 422 not_gated if the card's stage is not a gate. Returns the single-card shape.

POST /api/cards/:ref/reject

Send a card back. Requires a non-empty note (else 422 missing_note). Optional to (a stage id or name) routes to a specific earlier main-lane stage; an unresolvable target is 422 invalid_target. Without to, uses the gate's reject flow. Returns the single-card shape.

curl -X POST -H "Authorization: Bearer $RELAY_KEY" -H "Content-Type: application/json" \
  -d '{"note":"needs a test","to":"Plan"}' https://relay.example/api/cards/RLY-12/reject

POST /api/cards/:ref/archive

Archive a card: it leaves the board (open boards drop it live), an archived timeline entry is recorded against Relay AI, and any dependency rows that name it as a blocker are removed. Body {}. Returns the single-card shape with "archived": true. Archiving an already-archived card is a 200 that records nothing new.

A card with a live run is refused with 409 active_run and nothing is written — cancel the run first (POST /api/cards/:ref/cancel, or ./relay cancel REF), then archive. The board's own Archive button is not guarded this way.

curl -X POST -H "Authorization: Bearer $RELAY_KEY" -H "Content-Type: application/json" \
  -d '{}' https://relay.example/api/cards/RLY-12/archive
{ "error": { "code": "active_run", "message": "This card has a live run — cancel it (`relay cancel RLY-12`) before archiving" } }

POST /api/cards/:ref/unarchive

Restore an archived card to its stage (open boards re-insert it live) and record an unarchived timeline entry against Relay AI. Body {}. Returns the single-card shape with "archived": false. Unarchiving a card that is not archived is a 200 no-op. There is no live-run guard. Blockers removed when the card was archived are not restored.

curl -X POST -H "Authorization: Bearer $RELAY_KEY" -H "Content-Type: application/json" \
  -d '{}' https://relay.example/api/cards/RLY-12/unarchive

GET /api/cards/:ref/diagnosis

Why isn't this card moving? — one call, one plain-language verdict, plus the evidence behind it. Read-only and safe to hit while a run is live. The verdict is produced by replaying the real dispatch decision (Relay.Runs.Scheduler.explain/2, which shares its predicates with plan/1), not by a separate reimplementation, so it cannot drift from what actually dispatches.

curl -H "Authorization: Bearer $RELAY_KEY" https://relay.example/api/cards/RLY-12/diagnosis
{ "data": { "verdict": "awaiting_capacity",
  "detail": "The code flow would dispatch this card, but no runner is advertising a free shared_clean slot.",
  "evidence": { "card_ref": "RLY-12", "card_status": "ready", "flow_key": "code", "run_id": null } } }
verdict means
dispatchable would dispatch on the scheduler's next tick
blocked_by_dependencies the card declares blockers that have not reached a top-level Done column; evidence.blocked_by names their refs
no_enabled_flow no enabled flow pulls from this card's stage
flow_paused the enabled flow that pulls from this card's stage is paused by a broken board shape; evidence.problem carries what, why and fixes. Runs already going finish and land; fix the board and the next reconcile un-pauses it
awaiting_capacity a flow would dispatch; no runner advertises a free slot of the needed class
resume_refused a parked run's resume is being refused on every scheduler tick; evidence.resume_refused_reason names why (no_isolation · pin_unresolved · pinned_runner_absent · no_free_slot) and evidence.resume_refused_since when it started. After 30 minutes the reaper fails the run so relay retry applies
wip_full the works-in column (plus its sub-lanes) is at its WIP limit
owned_by_human a human holds the baton (ADR 0004)
blocked_on_input card status needs_input, or the run is parked needs_input
run_active a run is live; evidence.current_node names the node
not_eligible a flow pulls from this stage, but the card's status is not ready/queued
run_failed the card's last run failed; evidence.last_execution.detail carries the full failure text
job_stranded a job has sat queued/claimed past the grace with no live runner, and the board has at least one runner row. An EMPTY roster is the more fundamental fact and diagnoses no_runner instead (RE311)
job_awaiting_slot a live run's job has sat queued unclaimed past the grace and no connected runner has a free slot in its class; evidence.queued_age_s, evidence.isolation and evidence.runners (each %{name, used, total, held}) name the job's age, class, and who holds the slots
runner_outdated every connected runner is running code below the required minimum and is being refused at claim; evidence.required_version and evidence.running_versions name the mismatch
no_runner no runner is connected (or every one has gone silent), so nothing is running this board's node-jobs. Also the verdict for an aged unclaimed job when the roster is empty — nothing was ever holding it to go quiet on it (RE311)

CLI: ./relay why RLY-12.

GET /api/cards/:ref/runs

The card's runs, newest first, each with its node-execution history. detail and failure_detail are emitted in full — never truncated: this is the surface an operator reaches for instead of hand-writing an Ecto query over fly ssh console.

curl -H "Authorization: Bearer $RELAY_KEY" https://relay.example/api/cards/RLY-12/runs
{ "data": [ { "id": 7, "flow_key": "code", "status": "failed", "current_node": null,
  "failure_detail": "…", "node_executions": [
    { "node_key": "final_review", "visit": 1, "attempt": 2, "outcome": "failed", "detail": "…" }
  ] } ] }

CLI: ./relay runs RLY-12.

GET /api/runners

The board's connected runners: advertised capacity per isolation class, last heartbeat, freshness, version, and the jobs each is currently holding.

freshness is "fresh", "stale" (missed a beat), or "gone" (reclaimed by the reaper); stale? is a freshness != "fresh" convenience flag — check freshness when the distinction between "late" and "reclaimed" matters. outdated is orthogonal to freshness: a runner can be beating normally and still be running code below the server's minimum version, in which case it is refused work (409 runner_outdated) with no other visible symptom — this is the field that explains a healthy-looking runner that picks up nothing. A runner process that predates the rename to runner is refused with the same 409 runner_outdated and told to install ./relay (relay update) and restart it with ./relay start.

curl -H "Authorization: Bearer $RELAY_KEY" https://relay.example/api/runners
{ "data": [ { "name": "mac", "host": "mac.local", "capacity": { "shared_clean": 3, "exclusive": 1 },
  "last_heartbeat": "…", "freshness": "fresh", "stale?": false, "version": 1, "outdated": false,
  "jobs": [
    { "id": 12, "ref": "RLY-9", "node_key": "implement", "state": "running" }
  ] } ] }

CLI: ./relay runners.

GET /api/version

The git SHA the running app was built from. Unauthenticated — it leaks nothing a deploy does not. sha is "unknown" for any build made without the GIT_SHA build arg (a local mix phx.server, for instance): an honest "unknown" beats a misleading value.

curl https://relay.example/api/version
{ "sha": "0123456789abcdef0123456789abcdef01234567", "built_at": "2026-07-19T10:00:00Z", "version": "0.1.0" }

CLI: ./relay version.

GET /api/scaffold

The manifest of the five Relay-owned files this board serves: ./relay, the three relay-* skills, and relay.md (Relay.Scaffold.items/0). Unauthenticated, on purpose and on the same pipeline as GET /api/version — the board's install script (/b/<slug>/install) fetches them before anything has authenticated, so this has to be reachable with no Authorization header at all. 503 scaffold_unavailable if the board has not built its scaffold (mix relay.build_scaffold, which mix setup runs automatically); a deployed board never returns it.

curl https://relay.example/api/scaffold
{ "version": "0123456789ab", "items": [
  { "path": "relay", "sha256": "…", "bytes": 12345 }
] }

version is derived, never stored: the first 12 hex characters of the sha256 of the sorted "<path>:<sha256>" lines, so it changes exactly when content changes.

GET /api/scaffold/*path

One file's raw bytes. path is checked against the manifest's items — a static allowlist — before it ever reaches the filesystem, so this is not a general file server: any path outside the five Relay-owned files is 404 not_found. Unauthenticated, same reasoning as above.

relay is served with this board's URL baked into its BOARD_URL = "<url>" line (RE443), so a project needs no RELAY_URL. The URL is the board's configured one, never the request's Host. The manifest's sha256 for relay is that of the placeholder copy (BOARD_URL = ""), and ./relay update resets the line before comparing.

curl https://relay.example/api/scaffold/relay
curl https://relay.example/api/scaffold/.claude/skills/relay-update/SKILL.md

CLI: ./relay update fetches the manifest, hashes each of the five files against it, and re-downloads only the ones whose content differs — which covers a file that is missing, and one that has been edited. .relay/scaffold.json records what was installed; it is not the trigger.

POST /api/setup/preflight

The board's install script (curl -fsSL <host>/b/<slug>/install | sh, RE443) calls this before it writes anything; you rarely call it by hand. Board key required — it is the key's first authed call, which ticks setup step 3. It then records the preflight on step 4 (waiting when every check passed, failed with the fix text otherwise). Steps already past are left alone, so a re-run answers 200 and changes nothing. A board with no setup checklist answers the same way and records nothing.

curl -X POST https://relay.example/api/setup/preflight \
  -H "Authorization: Bearer $RELAY_API_KEY" -H 'Content-Type: application/json' \
  -d '{"slug": "streaks", "machine": "jl-mbp", "repo_dir": "~/src/streaks",
       "remote": "git@github.com:jl/streaks.git", "branch": "main",
       "checks": [{"id": "git_repo", "ok": true, "detail": "~/src/streaks"},
                  {"id": "remote", "ok": true, "detail": "origin → git@github.com:jl/streaks.git"},
                  {"id": "default_branch", "ok": true, "detail": "main"},
                  {"id": "python3", "ok": true, "detail": "/usr/bin/python3"}],
       "clis": {"claude": "/usr/local/bin/claude", "codex": null, "gemini": null}}'

checks must carry each local check id exactly once (git_repo, remote, default_branch, python3); clis maps each onboarding CLI to its path, or null when it isn't on PATH.

{ "ok": true, "board": "Streaks",
  "llm": { "harness": "claude-code", "label": "Claude Code", "cli": "claude",
           "skill_path": ".claude/skills/relay-onboard/SKILL.md", "pointer_file": "CLAUDE.md" },
  "checks": [
    { "id": "git_repo", "label": "git repo", "ok": true, "detail": "~/src/streaks", "fix": null },
    { "id": "remote", "label": "remote", "ok": true, "detail": "origin → git@github.com:jl/streaks.git", "fix": null },
    { "id": "default_branch", "label": "default branch", "ok": true, "detail": "main", "fix": null },
    { "id": "python3", "label": "python3", "ok": true, "detail": "/usr/bin/python3", "fix": null },
    { "id": "cli", "label": "claude on PATH", "ok": true, "detail": "/usr/local/bin/claude", "fix": null },
    { "id": "key", "label": "RELAY_API_KEY", "ok": true, "detail": "accepted by the board (…Wm7x)", "fix": null }
  ],
  "headline": null, "fix": null, "fix_command": null }

On a failed check ok is false, the check carries its fix, and headline / fix / fix_command describe the first failure (e.g. "Preflight stopped: Claude Code isn't on your PATH", "npm install -g @anthropic-ai/claude-code"). Errors: 403 wrong_board ("this key belongs to a different board") when slug is not the key's board; 409 step_2_pending when the board's setup hasn't picked a default LLM yet; 422 invalid for a malformed body or an unknown check id.

POST /api/setup/installed

The install script's success report, after it wrote the Relay files. Board key required. Finishes setup step 4 (activating step 5) when it is the current step; otherwise changes nothing.

curl -X POST https://relay.example/api/setup/installed \
  -H "Authorization: Bearer $RELAY_API_KEY" -H 'Content-Type: application/json' \
  -d '{"slug": "streaks", "machine": "jl-mbp", "repo_dir": "~/src/streaks", "branch": "main",
       "files": ["relay", ".claude/skills/relay-onboard/SKILL.md", "relay.md", "CLAUDE.md",
                 ".relay/runner.json", ".relay/prepare-worktree.sh", ".gitignore"]}'
{ "ok": true }

Errors: 403 wrong_board, 422 invalid.

GET /api/setup

The board's setup checklist, as the /relay-onboard skill reads it (RE445). Board key required. current is the one step a report may move — the current sub-step of step 5 while onboarding. referenced_files lists the .claude/ agent and skill files the board's flows name (a skill is present when either of its paths exists) — [] when they name none, as on every template board; a board copied from another board can name files its repo doesn't have yet. missing_file_resolutions are the ways step 5.4 resolves a missing one.

curl https://relay.example/api/setup -H "Authorization: Bearer $RELAY_API_KEY"
{ "data": {
    "board": "Streaks", "template": "Bare minimum",
    "llm": { "harness": "claude-code", "label": "Claude Code", "cli": "claude",
             "skill_path": ".claude/skills/relay-onboard/SKILL.md" },
    "current_id": "5", "current_sub_id": "5.1", "current": "5.1",
    "first_flow_stage": "Plan", "first_card_stage": "Backlog", "position": 5, "done_count": 4, "finished": false,
    "interview": [{ "id": "done", "label": "Done means", "question": "What does \"done\" mean for a card: …" }, …],
    "change_decisions": ["pending", "asking", "accepted", "rejected", "edited"],
    "missing_file_resolutions": ["stub", "inline", "drop", "path"],
    "referenced_files": [
      { "kind": "agent", "name": "plan-implementer", "paths": [".claude/agents/plan-implementer.md"],
        "used_by": [{ "flow": "code", "node": "implement" }] }
    ],
    "steps": [
      { "id": "5", "parent": null, "title": "Onboard, in Claude Code", "actor": "ai_terminal",
        "status": "active", "evidence": null, "error": null, "data": {},
        "reached_at": "2026-10-12T14:11:00Z", "completed_at": null,
        "children": [{ "id": "5.1", "parent": "5", "status": "active", …, "children": [] }, …] },
      …
    ] } }

Errors: 404 no_checklist ("no guided setup on this board") for a board created without a template.

POST /api/setup/steps/:step

Reports progress on the current step (RE445). Board key required. status is one of waiting, done, failed, skipped; evidence and error are optional strings (error is kept only for failed); data is an optional object merged at the top level into the step's data (5.3 sends every answers so far, 5.4 the whole changes list). done / skipped move on to the next step. Answers the same body as GET /api/setup. Repeating the report that just finished a step answers 200 and changes nothing.

curl -X POST https://relay.example/api/setup/steps/5.3 \
  -H "Authorization: Bearer $RELAY_API_KEY" -H 'Content-Type: application/json' \
  -d '{"status": "waiting", "data": {"answers": {"done": "PR merged"}}}'

Errors: 404 no_checklist; 409 not_current ("step 5.1 isn't current; the current step is 5.3", with the current step in error.current); 422 invalid for an unknown status, a non-string evidence / error, or a non-object data.


CLI

Most of the time you'll drive this API through the ./relay CLI rather than raw HTTP — it wraps pull/work/hand-back into a few commands. See relay.md in the repository for the full agent workflow.