Skip to content

Latest commit

 

History

History
1234 lines (967 loc) · 50.7 KB

File metadata and controls

1234 lines (967 loc) · 50.7 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
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 / 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,
  "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.

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