Skip to content

Latest commit

 

History

History
1384 lines (1090 loc) · 56.8 KB

File metadata and controls

1384 lines (1090 loc) · 56.8 KB

REST API Reference

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
GET    /api/projects/{project}/activity   ?limit=     # flattened activity-log feed (newest first; cap 500)
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/health                              # proxied runner /health (capacity meter; 2s cached)
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}/clear                           # clear runner context + re-prime + mark transcript
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 / OPTIONS on 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 /readyz dependency 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.

Health Endpoints

GET /healthz

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"}

GET /readyz

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:

  • readinessProbeGET /readyz
  • livenessProbeGET /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

Card list query parameters

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.

Card list response envelope

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 no cursor). 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", ...}]}

App Endpoints

GET /api/task-skills

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.

GET /api/app/config

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"}

Agent Endpoints

POST /api/projects/{project}/cards/{id}/usage

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.

POST /api/projects/{project}/cards/{id}/report-push

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.

Project Endpoints

POST /api/projects

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. EPICEPIC-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.

GET /api/projects / GET /api/projects/{project}

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.

PUT /api/projects/{project}

Update the project configuration. The update body is a subset of POST /api/projectsname, 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.

GET /api/projects/{project}/branches

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)

GET /api/projects/{project}/usage

Returns aggregated token usage across all cards in a project.

{
  "prompt_tokens": 45000,
  "completion_tokens": 12000,
  "estimated_cost_usd": 0.315,
  "card_count": 8
}

GET /api/projects/{project}/dashboard

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,
  "cards_completed_last_7d": 9,
  "cards_completed_prior_7d": 6,
  "metric_series": {
    "active_agents": [0, 1, 1, 2, 2, 1, 3, 2],
    "in_flight":     [1, 2, 2, 3, 3, 2, 4, 3],
    "stalled":       [0, 0, 1, 1, 0, 0, 0, 0],
    "shipped":       [1, 0, 2, 1, 1, 2, 1, 2]
  },
  "agent_costs": [
    {
      "agent_id": "claude-7a3f",
      "prompt_tokens": 30000,
      "completion_tokens": 8000,
      "estimated_cost_usd": 0.21,
      "card_count": 5
    }
  ],
  "model_costs": [
    {
      "model": "claude-sonnet-4-5",
      "prompt_tokens": 25000,
      "completion_tokens": 6000,
      "estimated_cost_usd": 0.18,
      "card_count": 4
    }
  ],
  "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.

model_costs aggregates token usage and cost per model across the project. Cards whose token-usage records have an empty model string are bucketed under "unknown". Each card is attributed to its most-recently-used model (cards that used multiple models show under the last one).

cards_completed_last_7d counts cards whose updated falls inside the trailing 7-day window ending at "now"; cards_completed_prior_7d counts the preceding 7-day window (used by the UI to render a week-over-week delta).

metric_series is an 8-sample daily window (oldest first, today last) for each tile on the board's metrics ribbon. Each slice always has exactly 8 entries. shipped is bucketed by updated on cards in the done state; the other three are reconstructed by walking each card's state_changed activity-log entries. The active_agents series counts cards whose reconstructed end-of-day state is in_progress/review and which currently have an assigned agent (claim history isn't tracked, so per-day agent presence is approximate). Cards that pre-date state-change logging fall back to their current state for the whole window.

GET /api/projects/{project}/activity

Returns a chronological flat feed of activity-log entries across every card in the project. Used by the board's NowRail "Activity" section to backfill entries older than the page load (SSE delivers everything from page load forward).

Query parameters:

  • limit (optional, default 50, max 500) — maximum number of entries to return. Invalid values (non-integer or <= 0) return 400.

Response envelope mirrors /cards — uses items rather than a bare array:

{
  "items": [
    {
      "agent": "claude-7a3f",
      "action": "claimed",
      "message": "",
      "card_id": "ALPHA-003",
      "ts": "2026-05-17T12:34:56Z"
    }
  ]
}

Entries are sorted newest-first by ts. The feed is rolling (no cursor): clients receive at most limit entries and refresh by re-fetching.

POST /api/projects/{project}/recalculate-costs

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 }

Sync Endpoints

POST /api/sync

Trigger a git pull on the boards repository. Returns 503 if sync is disabled (no remote configured).

GET /api/sync

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.

Knowledge Base Endpoints

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).

GET /api/projects/{project}/knowledge

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.

GET /api/projects/{project}/knowledge/{repo}/{doc}

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",
    "...": "..."
  }
}

PUT /api/projects/{project}/knowledge/{repo}/{doc}

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 }

GET /api/projects/{project}/knowledge/{repo}/refresh-plan

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.

POST /api/projects/{project}/knowledge/{repo}/refresh

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.

GET /api/projects/{project}/knowledge/refresh-status

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": ""
    }
  }
}

Runner Endpoints

See docs/remote-execution.md for the full webhook protocol, HMAC signing details, and runner configuration.

POST /api/projects/{project}/cards/{id}/run

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.

POST /api/projects/{project}/cards/{id}/message

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 content is empty
  • 413 CONTENT_TOO_LARGE if content exceeds 8 KiB
  • 409 RUNNER_NOT_RUNNING if the card is not running

Returns 202 with:

{ "ok": true, "message_id": "uuid-v4-string" }

POST /api/projects/{project}/cards/{id}/promote

Promote an interactive session to autonomous mode. Human-only. Requires runner_status: "running".

The endpoint performs two steps in order:

  1. Calls CardService.PromoteToAutonomous to flip the card's autonomous flag to true, append an activity log entry (action=promoted), commit the change to the boards git repository, and publish a CardUpdated SSE event. This step is idempotent — if the card is already autonomous, it returns the current card without writing a new log entry or commit.
  2. Sends a /promote webhook to the runner. The runner then writes a canned stdin message to the container instructing Claude Code to check the card with get_card at 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_FIELD if the caller is not a human agent
  • 409 RUNNER_NOT_RUNNING if runner_status is not "running"
  • 409 INVALID_TRANSITION if the card is in a terminal state (done or not_planned) — the flag flip itself is rejected before any webhook is sent
  • 502 RUNNER_UNAVAILABLE if 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"

POST /api/projects/{project}/cards/{id}/stop

Stop a running remote execution. Human-only. Sends kill webhook to runner. Returns 202 Accepted with the updated card (runner_status: "killed").

POST /api/projects/{project}/stop-all

Stop all running remote executions in a project. Human-only. Returns { "affected_cards": ["PROJ-001", "PROJ-003"] }.

GET /api/runner/health

Proxies a GET /health to the configured runner and returns the parsed shape. Used by the board's NowRail to render the capacity meter (max_concurrent is the runner-global cap, not a per-project value).

Returns:

{
  "ok": true,
  "running_containers": 2,
  "max_concurrent": 4
}
  • 503 RUNNER_DISABLED when the runner is not configured.
  • 502 RUNNER_UNAVAILABLE when the runner is configured but the probe fails (timeout, transport error, non-2xx response). Upstream error details are not surfaced in the response body — the underlying error is logged server-side. Callers should fail soft (hide capacity).

Probe results are cached server-side for ~2 seconds so concurrent browser tabs do not each fire a fresh probe. Probes use a tighter upstream timeout (3 s) than the runner client's default to keep the endpoint responsive during a runner outage.

GET /api/runner/logs

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 enters running until a terminal status (failed, killed, completed). Returns 204 if the session manager is unavailable.
  • Project-scoped (?project=P, no card_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 incoming seq > lastSeq + 1, inserts a client-side gap marker (type: 'gap') indicating the number of missing events.
  • dropped frames render as gap markers (not as ordinary log lines).
  • terminal frames clear connected and 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.

POST /api/runner/status

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"
}

POST /api/runner/skill-engaged

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}.

POST /api/runner/knowledge-status

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 → StateSucceeded
  • succeeded + !committedStateFailed("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.

GET /api/v1/cards/{project}/{id}/autonomous

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.

Chat Endpoints

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.

POST /api/chats

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.

GET /api/chats/models

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": ""}.

GET /api/chats

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.

GET /api/chats/{id}

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.

PATCH /api/chats/{id}

Update a session's title.

{ "title": "Renamed: auth-flow regression" }

Response: the updated ChatSession.

DELETE /api/chats/{id}

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.

POST /api/chats/{id}/open

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).

POST /api/chats/{id}/end

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.

POST /api/chats/{id}/clear

Clear the runner's working memory in place without ending the session. The server sends "/clear" to the runner, re-primes the session with the chat-mode primer (if configured), marks every prior transcript row with rehydration_phase = true so it is excluded from future cold-open resume payloads, and appends a divider row (role: system, content: "Context cleared", kind: "divider") that the UI renders as a horizontal rule. The divider is broadcast on the SSE wire AND persisted, so a page reload still shows the rule in the transcript.

Request: empty JSON body ({}). CSRF-gated; UI-only.

Responses:

Status Code Meaning
202 Cleared; body {"ok": true}
403 BAD_REQUEST Missing X-Requested-With: contextmatrix
404 CHAT_NOT_FOUND Unknown session id
409 RUNNER_NOT_RUNNING Session is not active or warm-idle (no live runner)
502 RUNNER_UNAVAILABLE Runner /clear or primer send failed (see detail)
500 INTERNAL_ERROR Persistence failure (rare; transcript-side)

On a 502 the transcript is left untouched — the operator can retry once the runner is reachable again. On 500 the runner has already been cleared but the transcript mark/divider step failed; the session is still usable, the divider just won't appear in the UI until the next clear.

The 502 response body includes a detail field that distinguishes the two failure stages:

detail Meaning
clear_failed The runner /clear call failed; primer was never attempted
primer_failed The /clear succeeded but the primer re-send failed; runtime is unoriented

Example SSE event for the divider (default unnamed channel, matching the existing message wire shape):

{
  "seq": 42,
  "role": "system",
  "content": "Context cleared",
  "kind": "divider"
}

rehydration_phase is omitted when false (omitempty on the wire), so it does not appear in a normal Clear Context event. It will be present and true only when Clear is invoked while the session is in its rehydration phase (rare).

POST /api/chats/{id}/messages

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" }

GET /api/chats/{id}/messages

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.

GET /api/chats/{id}/stream

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 older EventSource.onmessage listeners keep working. Payload:

    {
      "seq": 7,
      "role": "assistant_text",
      "content": "{\"text\":\"\"}",
      "rehydration_phase": false
    }

    rehydration_phase is omitted when false so the UI can group rehydration turns distinctly from normal traffic.

  • session_updated event — emitted with event: session_updated so the browser can listen on a named channel. Carries the same shape as the Session row (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).

MCP Endpoints

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.

chat_rehydration_complete

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