modules/task-board-api.md
The McRitchie Studio task board is the durable coordination surface for the
feature → task → PR → QA → deploy loop (see
devops-task-board.md for the workflow and stage
policy). This file documents the HTTP API an agent uses to create and move
tasks. The workflow doc describes what to record; this doc describes how to
talk to the board.
It is written against the live code (config/routes.rb,
app/controllers/api/v1/*, app/models/task.rb). When the code changes, update
this file in the same pass.
⚠️ Mostly current, with legacy examples below. The live task model is the
two-workflow 8-stage one (designed → building → submitted → reviewed →, plus
assembled → shippedblocked/archived). The endpoints table and
task-transition sections are current; any older examples that mention named
legacy transition routes should be treated as historical and replaced with
eitherPATCH stageor the event APIs documented here.Preferred path: use
bin/task. Don't hand-roll the HTTP calls below
unless you're debugging.bin/task create|update|move|list|showhandles auth,
JSON, devops read-merge-write, and stage routing for you, and reads the secret
viabin/secret. The raw API in this doc is the referencebin/taskis built
on. See the "Use bin/task" section at the end.
Every endpoint except POST /api/v1/auth requires a bearer token.
Rails.application.credentials.agent_api_secret || ENV["AGENT_API_SECRET"].
If neither is set, auth fails closed and no agent can authenticate.
Agent API Secret
(op://agents/Agent API Secret/AGENT_API_SECRET); also present in
mcritchie-studio/.env and the Heroku config. See
credential-inventory.md. POST /api/v1/auth
Content-Type: application/json
{ "secret": "<AGENT_API_SECRET>" }
Returns { "token": "...", "expires_at": "<iso8601>" }. The token is a Rails
MessageVerifier token (purpose api_auth), valid 24h.
3. Send it on every other call:
Authorization: Bearer <token>
Missing/invalid/expired tokens return 401 with
{ "error": "...", "error_code": "UNAUTHORIZED" }.
Prefer bin/secret agents 'Agent API Secret' AGENT_API_SECRET (value to
stdout, diagnostics to stderr, verifies op auth) over hand-rolling op read.
bin/task already uses it, so you usually never touch the secret directly.
If you must call the API by hand, never inline the secret or echo it — read it at
call time and pipe it straight into the request:
SECRET="$(/opt/homebrew/bin/op read 'op://agents/Agent API Secret/AGENT_API_SECRET')"
TOKEN="$(curl -sS -X POST https://mcritchie.studio/api/v1/auth \
-H 'Content-Type: application/json' \
-d "{\"secret\": \"$SECRET\"}" | python3 -c 'import json,sys;print(json.load(sys.stdin)["token"])')"
# use $TOKEN; never print $SECRET or $TOKEN
Sub-agent constraint: in sub-agent/headless sandboxes the
op read → curl secret chain (and redis-cli) is classifier-blocked. A
sub-agent therefore cannot drive this API directly — the orchestrator brokers
task-board writes on the sub-agent's behalf.
Base path /api/v1. From config/routes.rb:
| Method | Path | Purpose |
|---|---|---|
POST |
/auth |
Exchange secret → bearer token |
POST |
/release_notes |
Send canonical Discord release notes for deployed task slugs |
GET |
/tasks |
List tasks (newest first, paginated) |
GET |
/tasks/:slug |
Show one task |
POST |
/tasks |
Create a task |
PATCH/PUT |
/tasks/:slug |
Update a task |
DELETE |
/tasks/:slug |
Delete a task |
POST |
/tasks/:slug/intent |
Record live agent intent for a target stage |
POST |
/tasks/:slug/review_events |
Record a primary/light reviewer check-in |
POST |
/tasks/:slug/events/:stage/start |
Record a task transition start checkpoint |
POST |
/tasks/:slug/events/:stage/complete |
Complete a task transition stage/checkpoint |
POST |
/tasks/:slug/events/:stage/fail |
Fail a named task transition step and block the task |
POST |
/releases/:slug/events/:step/start |
Record a release checkpoint start (stamps the stage timeline; :slug accepts current) |
POST |
/releases/:slug/events/:step/complete |
Complete a release checkpoint (stamps the stage timeline; :slug accepts current) |
POST |
/releases/:slug/events/:step/fail |
Fail a release checkpoint (never stamps a stage) |
GET /tasks accepts ?stage=<stage> and ?agent_slug=<slug> filters (plus
?page / ?per_page) and returns { "data": [...], "meta": { page, per_page,. Each list item includes its
total, total_pages } }stage, so callers can
filter/triage without a per-task show. The filter param is stage, not
status — an unsupported query param is rejected with 400
{ "error": "unsupported query param(s): …", "error_code": "UNSUPPORTED_PARAM" }
rather than silently ignored (which used to return every task). The default is
per_page=20 (capped at 100), ordered newest-CREATED first (Task.recent =
created_at DESC) across all stages — so an unpaginated, unfiltered read
returns only the 20 most recent tasks. meta.total is the real count; a client
that ignores meta sees no truncation signal. To enumerate a stage in full,
filter with ?stage= (an actionable stage holds far fewer than 20 rows). A GET for an unknown slug returns
/tasks/:slug404 { "error": "task not found" }.
(There are also agents, activities, and usages resources; out of scope
here.)
POST /api/v1/release_notes is the canonical way to post production Release
Notes to Discord. Do not hand-compose the Discord message when this API is
available.
The endpoint:
https://mcritchie.studio/tasks/<task-slug>No deployed tasksDISCORD_RELEASE_NOTES_WEBHOOK_URL with
DISCORD_DEPLOY_WEBHOOK_URL as a compatibility fallbackRequest body:
{
"app": "mcritchie-studio",
"environment": "production",
"release": "v71",
"sha": "ef693ab1",
"url": "https://mcritchie.studio/",
"release_slug": "rel-2026-06-18-devops-tooling",
"task_slugs": ["task-abc123def456"],
"checks": ["production /up 200", "/signin 200", "/tasks 200", "web + worker dynos running"]
}
Use dry_run: true first to render and review the message without sending it:
api POST /api/v1/release_notes '{
"app": "mcritchie-studio",
"environment": "production",
"release": "v71",
"sha": "ef693ab1",
"url": "https://mcritchie.studio/",
"release_slug": "rel-2026-06-18-devops-tooling",
"task_slugs": ["task-abc123def456"],
"checks": ["production /up 200", "/signin 200", "/tasks 200", "web + worker dynos running"],
"dry_run": true
}'
Successful responses return { "data": { "delivered": true|false,.
"dry_run": true|false, "message": "...", "task_slugs": [...] } }
Unknown task slugs return 422 UNKNOWN_TASKS; missing webhook config on a live
send returns 422 MISSING_WEBHOOK.
Agents should prefer the transition endpoints for new automation. The legacy task
stage PATCH still exists, but transition endpoints give the board a deterministic
paper trail and enforce usage metadata on completed/failed agent work. The
underlying route still uses /events/ for backwards compatibility.
Task transition endpoints:
POST /api/v1/tasks/:slug/events/:stage/start
POST /api/v1/tasks/:slug/events/:stage/complete
POST /api/v1/tasks/:slug/events/:stage/fail
Known :stage values include the normal task stages
designed|building|submitted|reviewed|assembled|shipped|archived, plus named
checkpoints such as heavy_review, light_review, design, and
design_complete. start records an intent when the named stage is the task's
next workflow stage; otherwise it records a checkpoint. complete moves the
task when the stage is a real workflow stage; named review/design checkpoints
record an append-only TaskEvent(kind: checkpoint) without moving the task.
fail records the named failed checkpoint, then moves the task to blocked
with kind defaulting to rework.
Release checkpoint endpoints:
POST /api/v1/releases/:slug/events/:step/start
POST /api/v1/releases/:slug/events/:step/complete
POST /api/v1/releases/:slug/events/:step/fail
Canonical release steps are:
review_tests assemble_release deploy_qa qa_smoke ship_gate ship_authorized
deploy_prod prod_smoke release_notes archive_tasks
The tracker aliases also work: testing, assembling, qa_deploying,
confirming, and production_deploying.
The release carries an ordered set of stage timestamps — each acts as a time
AND a boolean (stamped = the stage started/landed, blank = not yet) — and the
/deployments progress tracker derives every node's green/yellow/dark state purely
from them. Posting a release event IS the stage notification: the server maps
the (step, status) pair to its stage stamp, first-write-wins (replays never
rewrite history), and broadcasts the live tracker to every viewer.
| You post | Stage stamped | Tracker effect |
|---|---|---|
testing/start (alias of review_tests/start) |
testing |
node 1 Testing yellow |
testing/complete (alias of review_tests/complete) |
tested |
(no tracker node) — ends the /deployments Tested column |
assembling/start |
assembling |
node 1 green, node 2 Assembling yellow |
assembling/complete |
assembled |
node 2 green (node 3 stays dark) |
qa_deploying/start |
qa_deploying |
node 3 Deploying QA yellow |
qa_deploying/complete |
qa_deployed |
node 3 green "Live on QA" (node 4 stays dark) |
confirming/start |
confirming |
node 4 Confirming yellow — the Avi handoff |
confirming/complete |
confirmed |
node 4 green (node 5 stays dark) |
production_deploying/start |
prod_deploying |
node 5 Deploying yellow |
| — | shipped |
node 5 green; only bin/release ship sets it, never an API post |
The gaps are deliberate: a completed stage does NOT light the next node. The
Steffon→Avi seam is the load-bearing case — Steffon's qa_deploying/complete
("Live on QA") leaves Confirming dark until Avi posts confirming/start.
:slug accepts the literal current to target the singleton active release
without a lookup. When nothing is active, current 404s — except the cycle
kick-off starts (testing/start, assembling/start), which may OPEN the next
candidate; a late-stage post never spawns a ghost release.
Every response carries the moved timeline so the poster can verify:
{ "data": { "step": "ship_gate", "status": "started",
"release": { "slug": "rel-20260704-a6ad35", "state": "assembled",
"stage": "confirming", "stage_stamps": { "qa_deployed": "…", "confirming": "…", "confirmed": null } } } }
bin/release (prepare/ship) records these same checkpoints server-side, so
CLI-driven stages stamp themselves — the API posts matter at the seams the CLI
cannot see: Avi beginning his QA confirmation (confirming/start), a review
wave kicking off the next cycle (testing/start), or manual recovery after an
interrupted run.
For complete and fail calls from agent/API/CLI sources, usage is mandatory:
{
"event": {
"actor": "avi",
"source": "api",
"model": "gpt-5",
"tokens_in": 12000,
"tokens_out": 1800,
"cost": "0.4200",
"idempotency_key": "rel-20260628-demo:ship_gate:complete"
}
}
start does not require usage because work has just begun. Deterministic
server-side writers such as bin/release use source: "conductor" and may
record spine-only events. Steps that take measurable work should write start
and then complete or fail; completed-only legacy checkpoints render as
instant activities in release analytics so their timestamp remains visible. Repeated
calls should pass idempotency_key so retries return the existing release event
instead of stacking duplicates.
Start/intents create the analytics timestamp; completions create the accounting
row. Do not put model/tokens/cost on start or intent calls. Agent/API/CLI
complete and fail calls must report model, tokens_in, tokens_out, and
cost; deterministic source: conductor|system completions may stay
spine-only.
Reviewer agents broadcast progress with:
POST /api/v1/tasks/:slug/review_events
It records a TaskEvent(kind: checkpoint) and does not move the task stage. The
task detail page links reviewed/live-review timeline cards to
/tasks/:slug/review_events, which groups these check-ins by the heavy and
light reviewer swimlanes. On a specific task, the reader derives each moment's
duration from the review intent and subsequent checkpoint timestamps; callers do
not send a duration. The deployments board's Submitted column also links to the
global review-process hub at /review_events, which shows the canonical moment
order, top recent role owners, and recent submitted/reviewed/assembled/shipped
task drilldowns.
Payload:
{
"review_event": {
"role": "primary",
"moment": "diff",
"status": "info",
"actor": "carl",
"source": "agent",
"message": "Routes, controller, and persistence diff scanned.",
"idempotency_key": "task-slug:primary:diff",
"metadata": { "pr": "https://github.com/amcritchie/mcritchie-studio/pull/123" }
}
}
Roles are primary and light. The UI labels primary as the heavy
swimlane. The legacy aliases heavy, heavy_review, and light_review are
accepted for API compatibility; they normalize to primary/light.
Canonical primary moments:
started context diff tests risk findings completed failed
Canonical light moments:
started context diff smoke handoff completed failed
status is started, info, completed, or failed; when omitted it is
derived from the moment (started, completed, and failed self-map,
everything else is info). completed and failed events from api, agent,
or cli sources must include model, tokens_in, tokens_out, and cost.
Mid-review started/info check-ins may be spine-only. Pass
idempotency_key on every automated broadcast so retries return the existing
checkpoint instead of stacking duplicates.
The older task transition aliases still work:
POST /api/v1/tasks/:slug/events/heavy_review/complete
POST /api/v1/tasks/:slug/events/light_review/complete
Use the dedicated review_events endpoint for new reviewer automation because
it captures the specific check-in moment and message.
POST/PATCH permit exactly (tasks_controller.rb#task_params):
title (required), descriptionpriority — 0, 1, or 2agent_slug — owning agent (optional)stage — see stages belowrequired_skills — array of stringsmetadata — free-form hashdevops — top-level object, normalized and stored at metadata.devopsslug is not writable — it is auto-generated as task-<hex> on create. The
human-readable handle lives in devops.worktree_slug; see
devops-task-board.md. Bind the generated production
task URL to the local stack with
bin/agent-worktree bind-task <app> <worktree-slug> <task-slug-or-url> so
terminal context and PR bodies can lead from the task record.
Eight stages (Task::STAGES):
designed → building → submitted → reviewed → assembled → shipped,
plus blocked and archived.
There are no named transition endpoints. Move stages with a raw update:
PATCH /api/v1/tasks/:slug { "stage": "submitted" }
Stage is also directly settable on create/update; transitions are not
guarded by a state machine, so any stage can be set to any value (only validated
against Task::STAGES). Follow the documented stage policy by convention.
Every stage change appends an append-only TaskEvent — the durable change
log behind the Stage Timeline on the task page (/tasks/<slug>). You get the
core of it for zero effort:
from_stage, to_stage, occurred_at,
and seconds_in_from (time spent in the stage you left). Moving the task is the
only action required; the duration is measured server-side, never passed.bin/task
auto-captures this usage for Claude (CLAUDE_CODE_SESSION_ID) and Codex
(CODEX_THREAD_ID) sessions from the local transcript when explicit usage
flags are absent; missing transcripts or unpriced models degrade to the
deterministic spine only.bin/task move <slug> submitted \
--model claude-opus-4-8 --tokens-in 240000 --tokens-out 96000 --cost 5.40 \
--actor alex # --actor optional; the working session is auto-stamped otherwise
Raw API: send a top-level event object alongside stage on the PATCH (it is
consumed for the event row only — not stored on the task):
PATCH /api/v1/tasks/:slug
{ "stage": "submitted",
"event": { "model": "claude-opus-4-8", "tokens_in": 240000,
"tokens_out": 96000, "cost": 5.40, "actor": "alex" } }
Backfill existing tasks once, from their stage-timestamp columns:
rake task_events:backfill (idempotent; reconstructed rows are flagged
source=system).
Source of truth: app/models/task_event.rb, Task#record_genesis_event /
#record_transition_event, and app/models/current.rb (the request-scoped
bridge that carries usage into the event).
Genesis and live building display. Created → Designed is the deterministic
task-creation marker. It has no actor/model/tokens/cost because the task slug and
usage baseline do not exist until the create call lands. bin/task create then
seeds the usage baseline, so design work is accounted on the next conclusion:
Designed → Building. While the task is currently building, the task detail
timeline marks that same Designed → Building card live; it does not append a
second Building card.
Release duration cache. Release::DurationCache derives stage durations from
task intents to conclusions (building, reviewing, assembled, shipped)
and release durations from ReleaseEvents. Cached metrics live on
releases.duration_metrics with duration_metrics_cached_at and
duration_cache_version. Task/release event writes refresh the owning release
best-effort, bin/rails releases:refresh_duration_metrics refreshes the last
three shipped releases, and /deployments/all plus /deployments/:slug render
from the cache with an in-memory fallback when a row is missing.
devops objectSend devops as a top-level key; it is normalized
(Task.normalize_devops_metadata) and merged into metadata.devops. Only these
keys survive (Task::DEVOPS_KEYS):
kind, worktree_slug, branch, pr_url, local_url, qa_url,
production_url, release_slug, requires_release_conductor,
approval_status, approval_requested_at, approval_requested_byrepositories, risk_tags, acceptance, test_plan,
checks_runupdate overwrites metadata.devops wholesale. If a PATCH includes a
devops object, it replaces the stored devops entirely — any field you
omit is lost. Re-send the full devops object on every update that touches
it. (A PATCH that omits devops leaves metadata untouched — use that to
move only the stage.) bin/task update does this read-merge-write for you,
so partial updates are safe through the CLI.normalize_devops_list treats
array input (the JSON API / bin/task) as already-delimited and splits it
only on newlines — so commas inside an acceptance/test_plan sentence
are preserved. String input (UI free-text fields) still splits on both
commas and newlines, so one field can carry several entries. Practical rule:
from the API/bin/task, always pass list values as arrays (one element
per item) and commas are safe.devops keys are silently dropped. Anything not in
DEVOPS_KEYS is discarded by the normalizer. To stash extra data, write it
under metadata directly instead of devops.slug is auto-generated, not settable — don't expect a human-readable
task slug from the API.BASE=https://mcritchie.studio
SECRET="$(/opt/homebrew/bin/op read 'op://agents/Agent API Secret/AGENT_API_SECRET')"
auth() { curl -sS -X POST "$BASE/api/v1/auth" -H 'Content-Type: application/json' \
-d "{\"secret\": \"$SECRET\"}" | python3 -c 'import json,sys;print(json.load(sys.stdin)["token"])'; }
TOKEN="$(auth)"
api() { curl -sS -X "$1" "$BASE$2" -H "Authorization: Bearer $TOKEN" \
${3:+-H 'Content-Type: application/json' -d "$3"}; }
# 1. Create (list values are arrays; commas inside an item are preserved)
api POST /api/v1/tasks '{
"title": "Add sticky header to admin users table",
"priority": 1,
"agent_slug": "shannon",
"devops": {
"kind": "feature",
"worktree_slug": "admin-users-sticky-header",
"repositories": ["mcritchie-studio"],
"risk_tags": ["ui"],
"acceptance": ["Header stays pinned while the table scrolls"],
"test_plan": ["bin/rails test"],
"checks_run": ["bin/rails test test/controllers/tasks_controller_test.rb"]
}
}' # -> returns the created task with slug "task-<hex>"
# 2. Claim it (creates/enters the worktree first, then:)
api PATCH /api/v1/tasks/task-XXXX '{"stage": "building"}'
# 3. Submit for review — PATCH the stage and
# RE-SEND the full devops (update overwrites it) plus branch + pr_url:
api PATCH /api/v1/tasks/task-XXXX '{
"stage": "submitted",
"devops": {
"kind": "feature",
"worktree_slug": "admin-users-sticky-header",
"repositories": ["mcritchie-studio"],
"risk_tags": ["ui"],
"branch": "feat/admin-users-sticky-header",
"pr_url": "https://github.com/amcritchie/mcritchie-studio/pull/123",
"acceptance": ["Header stays pinned while the table scrolls"],
"test_plan": ["bin/rails test"],
"checks_run": ["bin/rails test test/controllers/tasks_controller_test.rb"]
}
}'
# Preferred CLI path for the pre-PR operator validation gate:
bin/task update task-XXXX --local-url http://localhost:3001/admin/users --approval waiting
# 4. Review/merge/QA progression uses the same update path:
api PATCH /api/v1/tasks/task-XXXX '{"stage": "reviewed"}'
api PATCH /api/v1/tasks/task-XXXX '{"stage": "assembled"}' # devops preserved (no devops param)
# 5. Shipped (after approved deploy + post-deploy check):
api PATCH /api/v1/tasks/task-XXXX '{"stage": "shipped"}'
# 6. Production release notes (dry-run first, then repeat without dry_run):
api POST /api/v1/release_notes '{
"app": "mcritchie-studio",
"environment": "production",
"release": "v71",
"sha": "ef693ab1",
"url": "https://mcritchie.studio/",
"release_slug": "rel-2026-06-18-devops-tooling",
"task_slugs": ["task-XXXX"],
"checks": ["production /up 200", "/signin 200", "/tasks 200", "web + worker dynos running"],
"dry_run": true
}'
Cross-check against source when in doubt:
sed -n '/namespace :api/,/^ end/p' config/routes.rb # endpoints
grep -n "params.permit" app/controllers/api/v1/tasks_controller.rb # writable fields
grep -n "STAGES\|DEVOPS_KEYS\|normalize_devops" app/models/task.rb # stages + devops contract
bin/task (the preferred path)bin/task wraps everything above so you don't construct JSON, manage tokens, or
remember which stages have transition endpoints. It reads the secret via
bin/secret, does devops read-merge-write (partial updates never wipe
fields), and sends list flags as arrays so comma-containing items stay intact.
bin/task list [--stage S] [--agent A]
bin/task show <slug>
bin/task create --title T [--kind K] [--repo R ...] [--risk R ...] \
[--accept "..." ...] [--test "..." ...] [--agent A]
bin/task update <slug> --local-url U --approval waiting # request operator validation
bin/task update <slug> --approval approved # clear the pre-PR approval wait
bin/task update <slug> --branch B --pr-url U # merges into existing devops
bin/task move <slug> <stage> # bare Claude/Codex moves auto-capture usage
bin/task move <slug> submitted \ # optional per-transition usage →
--model M --tokens-in N --tokens-out N --cost D --actor A # recorded on the TaskEvent
⚠️
bin/task listcaps at 20 rows, recency-ordered across all stages, with
no truncation warning. It surfaces only the API's default page (per_page=20,
created_at DESC) and discardsmeta, so it prints(20 task(s))even when
more exist — older tasks in quiet apps silently fall off.bin/task listis the reliable enumeration (an actionable stage holds far
--stage <stage>
fewer than 20); enumerate the Deploy queue by stage at the start of every cycle
(seeparallel-agent-devops.md→ Step 0).
List flags are repeatable (one value per flag), so commas inside an
acceptance/test_plan item are safe. Fall back to the raw API above only when
bin/task can't express what you need.
We emailed a one-tap sign-in link to . It expires shortly and can only be used once.
No email? Check spam, or close this and try again.