GET /api/projects
POST /api/projects # create project
GET /api/projects/{project}
PUT /api/projects/{project} # update project config
DELETE /api/projects/{project} # delete project (requires 0 cards)
GET /api/projects/{project}/cards ?state=&type=&label=&agent=&parent=&priority=&external_id=&vetted=&limit=&cursor=
POST /api/projects/{project}/cards
GET /api/projects/{project}/cards/{id}
PUT /api/projects/{project}/cards/{id}
PATCH /api/projects/{project}/cards/{id}
DELETE /api/projects/{project}/cards/{id}
POST /api/projects/{project}/cards/{id}/claim # agent identity from X-Agent-ID header
POST /api/projects/{project}/cards/{id}/release # agent identity from X-Agent-ID header
POST /api/projects/{project}/cards/{id}/heartbeat # agent identity from X-Agent-ID header
POST /api/projects/{project}/cards/{id}/log { "action": "...", "message": "..." }
GET /api/projects/{project}/cards/{id}/context
POST /api/projects/{project}/cards/{id}/usage { "model": "...", "prompt_tokens": N, "completion_tokens": N }
POST /api/projects/{project}/cards/{id}/report-push { "branch": "...", "pr_url": "..." }
GET /api/projects/{project}/branches # list branches from project's GitHub repo
GET /api/projects/{project}/usage # aggregated token usage
GET /api/projects/{project}/dashboard # project dashboard metrics
POST /api/projects/{project}/recalculate-costs # recalculate token costs
GET /api/projects/{project}/knowledge # KB summary (repos + docs) for the project
GET /api/projects/{project}/knowledge/{repo}/{doc} # read one KB doc + metadata
PUT /api/projects/{project}/knowledge/{repo}/{doc} # save a hand-edited KB doc
GET /api/projects/{project}/knowledge/{repo}/refresh-plan # planned doc set for the next refresh
POST /api/projects/{project}/knowledge/{repo}/refresh # trigger a runner-driven KB refresh (human-only)
GET /api/projects/{project}/knowledge/refresh-status # in-flight refresh jobs for the project
POST /api/projects/{project}/cards/{id}/run # trigger remote execution (human-only)
POST /api/projects/{project}/cards/{id}/stop # stop running task (human-only)
POST /api/projects/{project}/cards/{id}/message # send chat message to running container (human-only)
POST /api/projects/{project}/cards/{id}/promote # promote interactive session to autonomous (human-only)
POST /api/projects/{project}/stop-all # stop all running tasks (human-only)
POST /api/runner/status # runner status callback (HMAC-signed; runner-enabled only)
POST /api/runner/knowledge-status # runner KB-refresh terminal callback (HMAC-signed; runner-enabled only)
POST /api/runner/skill-engaged # runner skill-engaged callback (HMAC-signed; runner-enabled only)
GET /api/runner/logs?project=&card_id= # SSE log stream (card-scoped or project-scoped; runner-enabled only)
GET /api/v1/cards/{project}/{id}/autonomous # runner-only autonomous flag read (HMAC-signed; runner-enabled only)
GET /api/chats ?project=&status=&created_by=&limit=
POST /api/chats # create a new chat session (cold)
GET /api/chats/models # chat model allowlist + default
GET /api/chats/{id}
PATCH /api/chats/{id} # rename a session
DELETE /api/chats/{id} # delete session and transcript
POST /api/chats/{id}/open # start (or reattach to) the chat container
POST /api/chats/{id}/end # stop the container; flip to cold
POST /api/chats/{id}/messages # send a user message into the active container
GET /api/chats/{id}/messages ?since_seq=&limit= # transcript bootstrap
GET /api/chats/{id}/stream ?since_seq= # SSE stream of new entries
POST /api/sync # trigger git sync
GET /api/sync # sync status
GET /api/task-skills # list available task skill names
GET /api/app/config # server-side app config (theme/palette/version)
GET /api/events?project= # SSE stream
GET /healthz # liveness probe (shallow)
GET /readyz # readiness probe (dependency-checked)
POST /mcp # MCP Streamable HTTP (Bearer auth; when MCP api key configured)
GET /mcp # MCP Streamable HTTP SSE channel
DELETE /mcp # MCP Streamable HTTP session close
Admin/debug server: when admin_port is configured (non-zero), a separate
HTTP server binds to admin_bind_addr (default 127.0.0.1) and serves:
GET /metrics— Prometheus text exposition format.GET /debug/pprof/*— Go runtime profiling (heap, goroutine, profile, etc.).
Neither endpoint is exposed on the main listener. The admin listener has no built-in authentication — keep it loopback-only, or gate it with a firewall / NetworkPolicy / service-mesh rule.
Agent identification: X-Agent-ID header is the sole source of agent
identity. It is required on the agent endpoints (/claim, /release,
/heartbeat, /log, /usage, /report-push) and on any mutation of a claimed
card — there the header value must match assigned_agent (403 on mismatch). It
is also used to gate human-only fields and human-only endpoints (/run,
/stop, /message, /promote, /stop-all, KB refresh trigger): those require
an X-Agent-ID value beginning with human:. Read endpoints, project CRUD,
sync, branches, app config, task-skills, healthz, and readyz do not require the
header. Request bodies on agent endpoints no longer carry an agent_id field —
it is silently ignored if present.
Identity is a tag, not auth. ContextMatrix is single-tenant and has no auth
layer below X-Agent-ID; spoofing it accomplishes nothing because there is no
permission gradient to escalate into. The human: prefix gates workflow
contracts (only humans promote / refresh KB), not security boundaries. The web
UI generates a per-browser identity (human:web-<8 hex chars>) and never
prompts the operator for a username. Two routes contain fall-backs that record
human:web (KB PUT) or human:api (runner /promote) when no header is
present — both are intentional, because the UI is the only legitimate caller.
See § Trust model in CLAUDE.md.
CSRF protection: every state-changing request on the main listener must
carry X-Requested-With: contextmatrix. The web UI sets this header on every
non-GET fetch in web/src/api/client.ts. Cross-origin browsers cannot set
custom headers on a "simple request" without a CORS preflight, and the server
serves no permissive CORS for state-changing routes — a missing header is
therefore a strong cross-origin signal and the request is rejected with 403
BAD_REQUEST. Exempt paths:
GET/HEAD/OPTIONSon any route (read-only)./api/runner/*— authenticated via HMAC, no browser path./mcp— Bearer-authed MCP endpoint./healthz,/readyz— probe endpoints.
The guard sits just outside the mux; any new state-changing route must opt in to the guard by not adding itself to the exempt list.
Request correlation: every response carries an X-Request-ID header. If the
client sends an X-Request-ID matching [A-Za-z0-9._-]{1,128} it is echoed;
otherwise the server generates a UUID. The same id is emitted as the
request_id attribute on every structured log line the request produces.
Error response format:
{
"error": "invalid state transition",
"code": "INVALID_TRANSITION",
"details": "cannot transition from 'todo' to 'done'; valid targets: [in_progress]"
}Response codes:
- 200: success (GET, PUT, PATCH; also
POST /claim,/release,/log,/usage,/report-push,/stop-all,/api/runner/status,POST /api/chats/{id}/open,POST /api/chats/{id}/end,GET /api/v1/cards/.../autonomous) - 201: created (
POST /api/projects,POST /api/projects/{p}/cards,POST /api/chats) - 202: accepted — async endpoint kicked off background work (
POST /run,/stop,/message,/promote, KB/refresh, chat/messages) - 204: deleted (DELETE) and
POST /heartbeat(no body) - 400: malformed input (bad JSON, missing/bad query param, unknown filter value,
missing CSRF header) — emitted with code
BAD_REQUEST - 403: agent mismatch (wrong agent trying to modify claimed card), unvetted card
claim attempt (
CARD_NOT_VETTED), agent attempting a human-only field mutation (HUMAN_ONLY_FIELD), HMAC signature / timestamp invalid on a runner-side endpoint (INVALID_SIGNATURE) - 404: card, project, KB doc, chat session, or referenced parent not found —
parent-not-found uses code
PARENT_NOT_FOUND - 409: conflict (invalid transition, card already claimed, already-running
runner task →
RUNNER_CONFLICT, KB refresh in flight →RUNNER_CONFLICT) - 413: request body / chat message exceeds the size cap (
CONTENT_TOO_LARGE) - 422: semantic validation error — mutation body references an unknown type,
state, priority, or invalid autonomous combination. Emitted with code
VALIDATION_ERROR. Not used for 400-class failures. - 429: concurrent chat cap reached (
TOO_MANY_CHATS) - 502: runner host unreachable (
RUNNER_UNAVAILABLE) - 503: runner not configured (
RUNNER_DISABLED), sync disabled (SYNC_DISABLED), or/readyzdependency check failed
Error code / HTTP status mapping (selected):
| Code | HTTP | Meaning |
|---|---|---|
BAD_REQUEST |
400 | malformed input / unknown filter value / CSRF missing |
PROJECT_NOT_FOUND |
404 | project slug does not exist |
CARD_NOT_FOUND |
404 | card ID does not exist in the project |
KNOWLEDGE_DOC_NOT_FOUND |
404 | KB doc path unknown or rejected (symlink) |
PARENT_NOT_FOUND |
404 | referenced parent card does not exist |
CHAT_NOT_FOUND |
404 | chat session ID does not exist |
VALIDATION_ERROR |
422 | mutation body semantically invalid |
INVALID_MODEL |
400 | chat model not in chat.models allowlist |
RUNNER_CONFLICT |
409 | card already queued/running, KB refresh already in flight |
RUNNER_DISABLED |
503/403 | runner not configured globally (503) or for the project (403) |
RUNNER_UNAVAILABLE |
502 | runner webhook failed (host unreachable) |
RUNNER_NOT_RUNNING |
409 | card is not currently running |
REVIEW_ATTEMPTS_CAPPED |
409 | review attempts limit reached |
INVALID_SIGNATURE |
403 | HMAC signature or X-Webhook-Timestamp missing / expired |
TOO_MANY_CHATS |
429 | configured chat.max_concurrent cap reached |
CONTENT_TOO_LARGE |
413 | message / request body exceeds the size cap |
PROTECTED_BRANCH |
403 | report-push targeted main / master |
NO_GITHUB_REPO |
404 | project repo is not a GitHub URL |
SYNC_DISABLED |
503 | sync trigger with no remote configured |
SYNC_ERROR |
500 | sync trigger raised an error |
APIError.details sanitization: downstream error strings that look like
go-git transport errors, ssh/exec failures, or absolute filesystem paths are
replaced with stable short labels ("git remote unreachable",
"git operation failed", "filesystem error") before being returned to
clients. The raw error is always logged server-side with the request's
request_id so operators can still investigate.
Error codes relevant to vetting:
| Code | HTTP | When |
|---|---|---|
CARD_NOT_VETTED |
403 | A non-human agent calls POST /claim on a card with source != null && vetted == false. |
HUMAN_ONLY_FIELD |
403 | An agent without human: prefix attempts to set autonomous, use_opus_orchestrator, feature_branch, create_pr, vetted, or base_branch. |
Shallow liveness probe. Always returns 200 OK with JSON body {"status":"ok"}
(Content-Type: application/json) as long as the process is running. No
dependency checks are performed.
Use this as a k8s livenessProbe target (or equivalent). Do not use it to gate
traffic — a 200 from /healthz only means the process has not crashed.
curl http://localhost:8080/healthz
# → {"status":"ok"}Dependency-checked readiness probe. Runs three checks with a 500 ms timeout:
| Check | What it tests |
|---|---|
store |
ListProjects succeeds (boards directory is readable) |
git |
CurrentBranch resolves (git manager is initialised) |
session_log |
always reports ok: true. A nil session-log manager simply means the runner is disabled (still healthy); a non-nil manager means it is operational. The check is included for forward compatibility but never fails the probe today. |
Returns 200 when all checks pass, 503 when any check fails.
Response body (200):
{
"status": "ok",
"checks": [
{ "name": "store", "ok": true },
{ "name": "git", "ok": true },
{ "name": "session_log", "ok": true }
]
}Response body (503):
{
"status": "degraded",
"checks": [
{
"name": "store",
"ok": false,
"error": "open /data/boards: permission denied"
},
{ "name": "git", "ok": true },
{ "name": "session_log", "ok": true }
]
}Use this as a k8s readinessProbe target. Kubernetes operators should point:
readinessProbe→GET /readyzlivenessProbe→GET /healthz
curl http://localhost:8080/readyz# Kubernetes probe example
readinessProbe:
httpGet:
path: /readyz
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 30| Parameter | Values | Description |
|---|---|---|
state |
state name | Filter by card state |
type |
type name | Filter by card type |
label |
label string | Filter cards that have this label |
agent |
agent ID | Filter by assigned_agent |
parent |
card ID | Filter by parent card |
priority |
priority name | Filter by priority |
external_id |
external ID | Filter by source.external_id (idempotent import check) |
vetted |
true / false |
Filter by vetted field. ?vetted=false lists unvetted external cards awaiting human review. |
limit |
1–2000 | Maximum items in the response page. Default 500. Out-of-range values return 400. |
cursor |
opaque string | Page continuation token from the previous response's next_cursor. Opaque to clients. |
GET /api/projects/{project}/cards returns a JSON object (not a bare array):
{
"items": [{ "id": "PROJ-001", "...": "..." }],
"next_cursor": "UFJPSi0wMDE",
"total": 1234
}items— page of cards, ordered by ID ascending. Always present (may be[]).next_cursor— opaque base64url token; pass back in?cursor=to fetch the next page. Omitted when the current page is the last page.total— total un-filtered card count for the project. Emitted only on the first page (when the request has nocursor). Callers can use it for "showing X of Y" indicators even while a filter is active.
Cursors encode the last card ID of the page and are stable across filter changes
— callers must treat them as opaque. Invalid cursors (not valid base64url)
return 400 BAD_REQUEST.
Ordering is by card ID ascending. The server sorts before slicing, so walking
next_cursor to exhaustion is guaranteed to visit every matching card exactly
once even though the underlying store iterates a map.
# First page — 1 item, includes total.
curl "http://localhost:8080/api/projects/alpha/cards?limit=1"
# → {"items":[{"id":"ALPHA-001", ...}],"next_cursor":"QUxQSEEtMDAx","total":3}
# Follow-up pages use cursor.
curl "http://localhost:8080/api/projects/alpha/cards?limit=1&cursor=QUxQSEEtMDAx"
# → {"items":[{"id":"ALPHA-002", ...}],"next_cursor":"QUxQSEEtMDAy"}
# Last page — next_cursor omitted.
curl "http://localhost:8080/api/projects/alpha/cards?limit=1&cursor=QUxQSEEtMDAy"
# → {"items":[{"id":"ALPHA-003", ...}]}Returns the list of task skills available in the configured task_skills.dir.
Each entry has a name (the skill directory name) and a description (read
from the skill's SKILL.md frontmatter). The response is a JSON object with a
skills array.
{
"skills": [
{
"name": "documentation",
"description": "Use when writing or updating documentation files."
},
{
"name": "go-development",
"description": "Use when implementing or modifying Go source files."
},
{
"name": "python-development",
"description": "Use when writing or modifying Python source files."
},
{
"name": "typescript-react",
"description": "Use when writing or updating React or TypeScript component files."
}
]
}Returns {"skills": []} if task_skills.dir is not configured or the directory
is empty. Used by the Project Settings UI to populate the
DefaultSkillsSelector.
Returns the server-configured application settings. Unauthenticated — safe for public read. Called by the frontend on startup to determine which color palette to apply.
Response:
{ "theme": "everforest", "version": "v0.42.0" }theme is one of "everforest" (default), "radix", or "catppuccin". The
frontend sets data-palette on <html> to match the theme value;
"everforest" removes the attribute (it is the default CSS block). version is
the build version string the binary was compiled with; it is always present and
may be empty when the binary is built without the version ldflag.
curl http://localhost:8080/api/app/config
# → {"theme":"everforest","version":"v0.42.0"}Report token usage for a card. Accumulates across multiple calls. Agent identity
is taken from the X-Agent-ID header.
{
"model": "claude-sonnet-4-6",
"prompt_tokens": 1234,
"completion_tokens": 567
}Returns 200 with the updated card. Cost is calculated automatically from
token_costs in config.yaml if the model matches a configured key. If the
model is not in the map, tokens accumulate normally but estimated_cost_usd
stays $0 for that delta; the contextmatrix_report_usage_unknown_model_total
counter is incremented (labeled by model) so operators can detect unconfigured
models.
Record a git push and optional PR URL on a card. Branch protection is enforced —
pushing to main or master returns 403 PROTECTED_BRANCH. Agent identity is
taken from the X-Agent-ID header.
{
"branch": "feat/user-auth",
"pr_url": "https://github.com/org/repo/pull/42"
}Returns 200 with the updated card.
Create a new project. Either name (slug) or display_name (human-readable)
must be provided; both may be provided together.
Request body:
{
"name": "epic-planner",
"display_name": "Epic Planner",
"prefix": "EPIC",
"repo": "git@github.com:org/epic-planner.git",
"states": ["todo", "in_progress", "review", "done", "stalled", "not_planned"],
"types": ["task", "bug"],
"priorities": ["low", "medium", "high"],
"transitions": {
"todo": ["in_progress", "not_planned"],
"in_progress": ["review", "todo"],
"review": ["done", "in_progress"],
"done": ["todo"],
"stalled": ["todo"],
"not_planned": ["todo"]
}
}Field rules:
| Field | Required? | Description |
|---|---|---|
name |
conditional | Slug — filesystem directory name, URL path segment, API identifier. Must match ^[a-zA-Z0-9][a-zA-Z0-9_-]*$. Auto-derived from display_name when omitted. |
display_name |
conditional | Human-readable project name. May contain spaces and any printable characters. Stored in .board.yaml; shown in the UI sidebar. |
prefix |
required | Card ID prefix (e.g. EPIC → EPIC-001). |
At least one of name or display_name is required (400 if both are absent).
Slug auto-derivation: when name is omitted, the server derives it from
display_name by lowercasing and collapsing runs of non-alphanumeric characters
to hyphens (e.g. "Epic Planner" → "epic-planner"). A 409 is returned if the
derived or explicit slug already exists as a project directory.
Response: 201 Created with the full ProjectConfig object, including the
stored name and display_name.
List all projects or get a single project by slug. Both responses include
display_name when set.
{
"name": "epic-planner",
"display_name": "Epic Planner",
"prefix": "EPIC",
"next_id": 1,
"states": ["..."],
"..."
}Existing projects without display_name omit the field; clients should fall
back to displaying name.
Update the project configuration. The update body is a subset of
POST /api/projects — name, display_name, and prefix are immutable and
not accepted here. Two extra fields are available: github (GitHub import
configuration) and default_skills (project-wide task-skill fallback).
Accepted fields:
{
"repo": "git@github.com:org/epic-planner.git",
"states": ["todo", "in_progress", "review", "done", "stalled", "not_planned"],
"types": ["task", "bug"],
"priorities": ["low", "medium", "high"],
"transitions": {
"todo": ["in_progress"],
"in_progress": ["review", "todo"],
"...": "..."
},
"github": { "...": "..." },
"default_skills": ["go-development", "documentation"]
}To rename a project or change its prefix, recreate it via POST /api/projects.
default_skills field — three-state semantics for the project-wide
task-skill fallback:
| Value | Meaning |
|---|---|
field omitted / null |
Clear: runner mounts the full curated task-skills set |
[] (empty array) |
Mount no task skills for cards without an explicit skills field |
["go-development", "documentation"] |
Constrain cards without explicit skills to this list |
Each name in default_skills must exist in the configured task_skills.dir —
unknown names return 400 VALIDATION_ERROR. A card's own skills field
(including explicit empty) always overrides the project default.
The Project Settings UI exposes this as the Default task skills selector with "Mount full set" / "Mount no skills" / "Constrain to selected skills" radio buttons.
Returns 200 with the updated ProjectConfig.
Returns a JSON array of branch name strings for the project's GitHub repository. Used by the card editor to populate the base branch dropdown.
Returns 404 with NO_GITHUB_REPO if the project's repo field is not a GitHub
URL. If GitHub credentials are missing or the upstream API call fails the
handler currently returns 500 INTERNAL_ERROR with the underlying error logged
server-side.
["main", "develop", "release/v2", "feat/some-branch"]Error codes:
| Code | HTTP | When |
|---|---|---|
NO_GITHUB_REPO |
404 | Project repo is not a GitHub repository URL |
INTERNAL_ERROR |
500 | GitHub branch fetch failed (auth, network, upstream API) |
Returns aggregated token usage across all cards in a project.
{
"prompt_tokens": 45000,
"completion_tokens": 12000,
"estimated_cost_usd": 0.315,
"card_count": 8
}Returns dashboard metrics for a project.
{
"state_counts": { "todo": 3, "in_progress": 2, "done": 5 },
"active_agents": [
{
"agent_id": "claude-7a3f",
"card_id": "ALPHA-003",
"card_title": "...",
"since": "...",
"last_heartbeat": "..."
}
],
"total_cost_usd": 0.315,
"cards_completed_today": 2,
"agent_costs": [
{
"agent_id": "claude-7a3f",
"prompt_tokens": 30000,
"completion_tokens": 8000,
"estimated_cost_usd": 0.21,
"card_count": 5
}
],
"card_costs": [
{
"card_id": "ALPHA-003",
"card_title": "...",
"assigned_agent": "claude-7a3f",
"prompt_tokens": 5000,
"completion_tokens": 1200,
"estimated_cost_usd": 0.033
}
]
}assigned_agent is omitted when no agent currently owns the card.
Recalculate estimated costs for all cards in a project using current
token_costs rates. Requires default_model for cards that have tokens but no
model recorded.
{ "default_model": "claude-sonnet-4-6" }Returns:
{ "cards_updated": 12, "total_cost_recalculated": 0.847 }Trigger a git pull on the boards repository. Returns 503 if sync is disabled (no remote configured).
Returns current sync status.
{
"last_sync_time": "2026-04-05T12:00:00Z",
"last_sync_error": "",
"syncing": false,
"enabled": true
}| Field | Type | Description |
|---|---|---|
last_sync_time |
RFC 3339 / null |
Timestamp of the last completed sync attempt; null if none. |
last_sync_error |
string (omitempty) | Error message from the most recent failed sync. |
syncing |
bool | true while a sync is in flight. |
enabled |
bool | Whether automatic sync is enabled in config. |
The KB stores per-project, per-repo markdown documents (overview,
directory-map, interfaces, etc.). See docs/data-model.md for the full list
of doc names. Reads are unauthenticated; writes are gated by the CSRF middleware
(UI-origin signal) and either the human: prefix (refresh) or the human:web
fallback (single-doc edit).
Returns the KB summary for one project — a list of repos with their docs and
last-built timestamps. Repos configured in .board.yaml that have no built KB
content yet are included as stub entries (no docs, no built_at) so the UI
sidebar can render a "Refresh" button for every configured repo from the first
visit. Repos are sorted by name.
Returns the markdown content and metadata for one KB doc. Symlinks under the KB
tree are rejected (404 KNOWLEDGE_DOC_NOT_FOUND).
{
"content": "# Repo overview\n\n...",
"meta": {
"human_edited": true,
"built_at": "2026-05-10T08:12:33Z",
"...": "..."
}
}Save a hand-edited doc. The body is {"content": "..."}. An empty or absent
content returns 400 — to remove a doc, refresh and exclude it from the
overwrite list (or delete via the boards repo). The handler tags the write as
human_edited=true and records the agent ID (human:web when the header is
absent — UI is the only legitimate caller).
{ "files_written": 1 }Returns the set of docs that the next refresh job would (re)build for the given repo, taking the current human-edited / freshness state into account. Used by the UI to preview the refresh impact before triggering one.
Trigger a runner-driven knowledge-base refresh for one repo. Human-only
(X-Agent-ID must start with human:). Returns 503 RUNNER_DISABLED when the
runner is not configured, 409 RUNNER_CONFLICT when a refresh is already in
flight for the same (project, repo), and 502 RUNNER_UNAVAILABLE when the
runner webhook fails.
Accepts an optional JSON body to override the default plan and force a re-build of specific docs:
{ "overwrite_docs": ["overview", "directory-map"] }Each name must be in the curated board.KnowledgeDocNames allowlist — the
runner enforces the same set, this is defence-in-depth at the CM boundary.
Returns 202 Accepted with the initial job state on success.
Returns a map of in-flight or recently-finished refresh jobs for the project,
keyed by repo name. Each entry carries the job state (running / succeeded /
failed), progress counters, the agent that triggered it, the commit SHA on
success, and the optional error message on failure.
{
"repos": {
"contextmatrix": {
"state": "running",
"agent_id": "human:alice",
"started_at": "2026-05-14T09:11:32Z",
"finished_at": null,
"docs_total": 8,
"docs_done": 3,
"current_doc": "interfaces",
"error": "",
"commit_sha": ""
}
}
}See docs/remote-execution.md for the full webhook
protocol, HMAC signing details, and runner configuration.
Trigger remote execution for a card. Human-only (rejects X-Agent-ID without
human: prefix). Requires card to be in todo state and runner enabled
globally + per-project. The autonomous flag is not required.
Accepts an optional JSON body:
{ "interactive": true }When interactive is true, the container starts in Human-in-the-Loop (HITL)
mode — the runner writes a priming stream-json user message to the container's
stdin after start, which instructs Claude to invoke get_skill(create-plan)
immediately. The user provides approval at the skill's built-in gates (plan
approval, subtask execution decision, review) via the chat input.
Regardless of interactive, feature_branch and create_pr are automatically
enabled on the card for all run triggers (both autonomous and HITL).
Returns 202 Accepted with the updated card (runner_status: "queued"). The
response is returned as soon as the runner webhook is accepted — the runner then
provisions the container asynchronously.
Send a chat message to a container running in interactive mode. Human-only.
Requires runner_status: "running".
{ "content": "Please focus on the authentication module first." }- 422 if
contentis empty - 413
CONTENT_TOO_LARGEifcontentexceeds 8 KiB - 409
RUNNER_NOT_RUNNINGif the card is not running
Returns 202 with:
{ "ok": true, "message_id": "uuid-v4-string" }Promote an interactive session to autonomous mode. Human-only. Requires
runner_status: "running".
The endpoint performs two steps in order:
- Calls
CardService.PromoteToAutonomousto flip the card'sautonomousflag totrue, append an activity log entry (action=promoted), commit the change to the boards git repository, and publish aCardUpdatedSSE event. This step is idempotent — if the card is already autonomous, it returns the current card without writing a new log entry or commit. - Sends a
/promotewebhook to the runner. The runner then writes a canned stdin message to the container instructing Claude Code to check the card withget_cardat its next gate and continue on the autonomous branch.
feature_branch and create_pr are also set to true if not already enabled.
Error responses:
- 403
HUMAN_ONLY_FIELDif the caller is not a human agent - 409
RUNNER_NOT_RUNNINGifrunner_statusis not"running" - 409
INVALID_TRANSITIONif the card is in a terminal state (doneornot_planned) — the flag flip itself is rejected before any webhook is sent - 502
RUNNER_UNAVAILABLEif the runner webhook fails — the autonomous flag flip is not reverted; the card is permanently promoted
Returns 202 Accepted with the updated card. The idempotent short-circuit (card already autonomous) also returns 202 with the current card state and no new log entry.
curl -X POST http://localhost:8080/api/projects/my-project/cards/PROJ-042/promote \
-H "X-Agent-ID: human:alice"Stop a running remote execution. Human-only. Sends kill webhook to runner.
Returns 202 Accepted with the updated card (runner_status: "killed").
Stop all running remote executions in a project. Human-only. Returns
{ "affected_cards": ["PROJ-001", "PROJ-003"] }.
SSE log stream. Only available when runner is enabled (runner.enabled: true in
config). Not authenticated — the browser connects directly; HMAC signing is
performed server-side toward the runner.
Query parameters:
| Parameter | Required | Description |
|---|---|---|
project |
recommended | Filter entries to a single project |
card_id |
optional | Enable card-scoped session mode (see below) |
Two modes, selected by card_id:
- Card-scoped (
?project=P&card_id=X): connects to the server-side session manager. The server first replays all buffered events (snapshot), then tails live events. A client that reconnects receives all events from the start of the session, including any HITL questions. The session exists from when the card entersrunninguntil a terminal status (failed,killed,completed). Returns 204 if the session manager is unavailable. - Project-scoped (
?project=P, nocard_id): connects to the server-side session manager for the project. The server first replays all buffered project events (snapshot), then tails live events — identical replay guarantee as the card-scoped path. Used by the Runner Console panel. A reconnecting client receives all events buffered since the console was first opened. Returns 204 if the session manager is unavailable.
Response: Content-Type: text/event-stream. The server sets
X-Accel-Buffering: no on all SSE responses to bypass nginx proxy buffering. A
: keepalive\n\n comment is written every 30 seconds per subscription to
survive Cloudflare/nginx idle timeouts (~100 s).
Each normal event carries a JSON payload:
{
"ts": "2026-04-08T12:34:56.789Z",
"card_id": "PROJ-042",
"type": "text",
"content": "Planning the implementation...",
"seq": 42
}Marker frames have a distinct shape:
| Frame type | Payload shape | Meaning |
|---|---|---|
terminal |
{"type":"terminal","seq":N} |
Session ended; no further events |
dropped |
{"type":"dropped","seq":N,"count":N} |
Server ring-buffer overflowed; count events were evicted |
type for normal events is one of: text, thinking, tool_call, stderr,
system, user.
The connection is closed when the browser disconnects or the session receives a terminal event.
Client behaviour (useRunnerLogs):
- Tracks last-seen
seq; if an incomingseq > lastSeq + 1, inserts a client-side gap marker (type: 'gap') indicating the number of missing events. droppedframes render as gap markers (not as ordinary log lines).terminalframes clearconnectedand stop the reconnect loop — no further reconnect is attempted after a clean session end.
See docs/remote-execution.md for the full log streaming
architecture, LogEntry type details, and session manager configuration.
Runner callback endpoint. Requires both an X-Signature-256 header
(HMAC-SHA256, prefixed with sha256=) and an X-Webhook-Timestamp header (used
for clock-skew rejection). Missing either header, a malformed signature, or an
expired timestamp returns 403 INVALID_SIGNATURE. Only registered when the
runner is enabled in config.
Accepts runner_status updates ("running", "failed", "completed"). The
server-only statuses "queued" and "killed" are rejected with 422
VALIDATION_ERROR.
{
"card_id": "PROJ-042",
"project": "my-project",
"runner_status": "running",
"message": "container started"
}Runner callback endpoint reporting that the in-container Claude session engaged
a workflow skill. Same HMAC authentication as /api/runner/status
(X-Signature-256 + X-Webhook-Timestamp). Only registered when the runner is
enabled. Used for runner-side telemetry; the response body is {"ok": true}.
Runner terminal callback for a KB refresh job. Same HMAC authentication as
/api/runner/status. Only registered when the runner is enabled. The body
carries the project, repo, runner-reported terminal state, and an optional error
message:
{
"project": "contextmatrix",
"repo": "contextmatrix",
"state": "succeeded",
"error": ""
}The server reconciles the reported state against the registry's committed flag
(which is set by the commit_knowledge_docs MCP tool side effect):
succeeded+ committed →StateSucceededsucceeded+!committed→StateFailed("commit not observed")- any other state →
StateFailed
Responds with {"ok": true, "tracked": <bool>}. tracked is false when no
in-flight job is found for the (project, repo) pair, in which case the
callback is acknowledged but not acted on.
Runner-only read endpoint. Authenticated with HMAC-SHA256 over an empty body
(X-Signature-256 + X-Webhook-Timestamp). Returns the minimal shape
{"autonomous": <bool>} so the runner can fail-closed verify a card's
autonomous flag during /promote before writing the canned stdin message. Only
registered when the runner is enabled. No other card fields are exposed on this
path.
Project-agnostic chat sessions that share the runner's worker image but use
long-lived containers instead of card-scoped one-shots. Identity follows the
same X-Agent-ID tagging convention as the rest of the API (see § Trust model
in CLAUDE.md); the web UI defaults to human:web when the header is absent.
Create a new session row. Status starts at cold; no container is started yet.
Request body:
{
"title": "Investigate auth-flow regression",
"project": "contextmatrix",
"model": "claude-opus-4-7"
}All three fields are optional. An empty title is auto-filled from the first
user message; project may be empty for cross-project chats. model selects
the orchestrator model for this session; omit to use chat.default_model. The
value must be a key from chat.models — unknown IDs return 400
(INVALID_MODEL). The choice is persisted on the session row and forwarded to
the container as CM_ORCHESTRATOR_MODEL on every /open.
Response (201 Created): the new ChatSession row.
List the chat model allowlist and the configured default. Used by the New Chat dialog to populate the model picker.
Response:
{
"models": [
{
"id": "claude-haiku-4-5-20251001",
"label": "Haiku 4.5",
"max_tokens": 200000
},
{ "id": "claude-opus-4-7", "label": "Opus 4.7", "max_tokens": 1000000 },
{ "id": "claude-sonnet-4-6", "label": "Sonnet 4.6", "max_tokens": 1000000 }
],
"default": "claude-sonnet-4-6"
}Models are sorted by id. When chat is disabled in config the response is
{"models": [], "default": ""}.
List sessions, newest-first by last_active. Query parameters:
| Param | Default | Max | Effect |
|---|---|---|---|
project |
— | — | Filter by project name (omit for all) |
status |
— | — | Filter by cold / active / warm-idle / ending. Unknown values return 400. |
created_by |
— | — | Filter by agent ID (e.g. human:web-1a2b3c4d) |
limit |
500 |
5000 | Cap on rows returned; out-of-range values clamp / 400 |
Response: a JSON array of Session. Always [], never null.
Returns the ChatSession row. 404 (CHAT_NOT_FOUND) if unknown.
Response fields that the UI header consumes:
| Field | Type | Meaning |
|---|---|---|
model |
string | Orchestrator model ID. Set at creation; reused on every /open. |
context_tokens |
int | Last input + cache_read + cache_create reported by Claude. Updated on every assistant turn. |
context_tokens_updated_at |
RFC3339 | Timestamp of the last context_tokens update. Zero (0001-01-01T00:00:00Z) until the first usage entry. |
rehydration_active |
bool | true between cold-reopen and the agent's chat_rehydration_complete call. Drives the "Restoring workspace…" banner. Omitted when false. |
Update a session's title.
{ "title": "Renamed: auth-flow regression" }Response: the updated ChatSession.
Removes the session and its transcript (FK cascade on chat_messages). If the
session is active or warm-idle, the runner container is ended first.
Transition a cold session to active by starting the chat container. Idempotent
for active sessions; reattaches to the existing container for warm-idle.
Returns 429 (TOO_MANY_CHATS) when the configured chat.max_concurrent cap
is reached.
Response: the refreshed ChatSession (now with status: active and a
container_id).
End the session: closes the container's stdin and force-stops it. Status flips
to cold; container_id is cleared.
Response (200 OK): the refreshed ChatSession in the cold state.
Send a user message into the active chat container. The Manager appends the
message to the transcript with a server-assigned seq, broadcasts a user event
on the per-session SSE hub, then forwards the message to the runner.
Request:
{ "content": "Show me the diff between v1 and v2 of the auth middleware." }content is capped at 8 KiB (413 on overflow).
Response (202 Accepted):
{ "ok": true, "message_id": "msg-1234abcd" }Bootstrap endpoint that returns persisted transcript rows from SQLite, ordered oldest-first. Used by the browser on Chat tab mount (and on refresh) to backfill the in-memory ring buffer beyond what the SSE in-memory replay (128 entries) can cover.
Query parameters:
| Param | Default | Max | Effect |
|---|---|---|---|
since_seq |
0 |
— | Exclusive lower bound: returns seq > N. |
limit |
200 |
1000 | Cap on rows returned. Values above clamp. |
Response:
{
"messages": [
{
"id": 1,
"session_id": "01J...",
"seq": 1,
"role": "user",
"content": "{\"text\":\"hi\"}",
"created_at": "2026-05-14T12:00:00Z"
},
{
"id": 2,
"session_id": "01J...",
"seq": 2,
"role": "assistant_text",
"content": "{\"text\":\"hello\"}",
"created_at": "2026-05-14T12:00:01Z"
}
]
}Empty transcripts return {"messages": []}. Invalid since_seq / limit
return 400 BAD_REQUEST. Unknown session returns 404 CHAT_NOT_FOUND.
The browser pairs this REST bootstrap with the SSE /stream endpoint: fetches
all messages with since_seq=0, records the highest seq, then subscribes to
/stream?since_seq=<last> so the seam is gapless. SSE events whose seq falls
inside the REST window are deduped on the client.
Server-Sent Events stream of new transcript entries for one session. Two event kinds share the wire:
-
Default (transcript) event — emitted without an SSE
event:header so olderEventSource.onmessagelisteners keep working. Payload:{ "seq": 7, "role": "assistant_text", "content": "{\"text\":\"…\"}", "rehydration_phase": false }rehydration_phaseis omitted when false so the UI can group rehydration turns distinctly from normal traffic. -
session_updatedevent — emitted withevent: session_updatedso the browser can listen on a named channel. Carries the same shape as theSessionrow (or{}when the manager has no update to attach). Used to push status / context-token changes without a full re-fetch.
Query parameter: since_seq=<N> (replay events where seq > N from the
server-side 128-entry ring buffer before tailing live events). The handler
flushes a : connected\n\n comment immediately on subscribe so browsers see
onopen fire before any event lands, and sends : keepalive\n\n every 15
seconds. SSE write deadlines are cleared per-connection so the stream survives
the server-wide WriteTimeout. Subscribing to an unknown session returns
404 CHAT_NOT_FOUND (the handler validates the session exists before reaching
the hub).
The MCP (Model Context Protocol) server is mounted at /mcp when an MCP API key
is configured. The same handler is registered for POST /mcp, GET /mcp, and
DELETE /mcp per the MCP Streamable HTTP transport spec. Authentication uses a
Bearer <api-key> Authorization header. The path is exempt from the CSRF
guard. See docs/agent-workflow.md for the tool and prompt
catalogue.
Marks the active chat session's rehydration phase as complete and emits the final summary message. Called by a chat-mode worker after it has finished ingesting the rehydration prompt.
Identity gate: the caller's X-CM-Chat-Session header must equal the
session_id parameter; otherwise the call is rejected. The empty-caller case
(no header) is allowed for card-mode and out-of-band callers, but the session_id
must still resolve to an active chat session.
Parameters:
session_id(string, required)summary(string, required) — surfaced to the UI as an assistant message