Files
AgentHub/docs/API.md
T
nntrivi2001 20d85d3450 feat(workflows): pick a lane in the pipeline pass-rate panel
The panel aggregated every lane at once, which answers "does this stage get
proved across the project" but never "does THIS lane prove it". A lane picker
now narrows the same aggregation through ?lane=<id> on
GET /api/lanes/pipeline-stats, counting that lane plus its own archived
features — dropping the features would hide most of a lane's history the moment
it starts its next one.

The response carries a `lanes` array that the filter never narrows: it
populates the picker, so it has to keep offering every other lane even while
the counts show one. For the same reason the picker renders above the empty
branch rather than inside the populated one — narrowing to a lane that has
recorded nothing would otherwise be a one-way trip, with no control left to
select anything else. A lane filter that is not a positive integer is rejected
with 400 EBADLANE rather than silently aggregating everything.
2026-08-26 11:19:39 +07:00

2058 lines
71 KiB
Markdown

# API Reference
Complete REST API and WebSocket documentation for Agent Dashboard.
---
## Table of Contents
- [Overview](#overview)
- [Authentication](#authentication)
- [Base URL](#base-url)
- [REST API](#rest-api)
- [Sessions](#sessions)
- [Agents](#agents)
- [Tools](#tools)
- [Metrics](#metrics)
- [Pricing](#pricing)
- [Lanes](#lanes)
- [Notifications](#notifications)
- [Remote Data Sources](#remote-data-sources)
- [WebSocket API](#websocket-api)
- [Error Handling](#error-handling)
- [Rate Limiting](#rate-limiting)
- [Pagination](#pagination)
- [Examples](#examples)
---
## Overview
The Agent Dashboard API provides programmatic access to Claude Code session monitoring data.
```mermaid
graph LR
Client[API Client] -->|HTTP/HTTPS| REST[REST API<br/>:4820/api/*]
Client -->|WebSocket| WS[WebSocket<br/>:4820/ws]
REST --> DB[(SQLite)]
WS --> Broadcast[Real-time<br/>Broadcasts]
style REST fill:#10B981
style WS fill:#F59E0B
style DB fill:#003B57,color:#fff
```
**Protocols:**
- **REST API** - HTTP/JSON for queries and mutations
- **WebSocket** - Real-time event streaming
---
## Authentication
The server is **local-first** and is hardened to keep the dashboard off the network by default (see GHSA-gr74-4xfh-6jw9). The trust boundary is the loopback bind, layered with origin and host checks:
- **Loopback bind by default** — the server binds `127.0.0.1`, so it is not network-reachable out of the box. Operators opt into a wider bind with `DASHBOARD_HOST` (e.g. `DASHBOARD_HOST=0.0.0.0` for LAN access), which logs a startup warning.
- **CORS restricted to loopback origins** — cross-origin web pages cannot read API responses. Requests with no `Origin` (curl, server-to-server) still work.
- **Host-header allowlist** — both HTTP requests and WebSocket upgrades are checked against an allowlist to block DNS-rebinding. Add extra LAN names (when you bind beyond loopback) via `DASHBOARD_ALLOWED_HOSTS` (comma-separated).
For deliberate LAN exposure, set `DASHBOARD_HOST` to a non-loopback address and list the names clients use in `DASHBOARD_ALLOWED_HOSTS`.
### Optional token (`DASHBOARD_TOKEN`)
Authentication is **off by default** (the loopback bind is the trust boundary). When `DASHBOARD_TOKEN` is set, every `/api/*` request **and** the WebSocket must present the token. It is strongly recommended whenever you bind beyond loopback. Pass it any of these ways:
- `Authorization: Bearer <token>` header
- `x-dashboard-token: <token>` header
- `?token=<token>` query parameter
These paths stay exempt even when a token is configured: `/api/health`, `/api/openapi.json`, `/api/docs`, and `/api/hooks` (local Claude Code hook ingestion). Requests that fail the check get `401` with error code `EUNAUTHORIZED`.
`GET /api/settings/info` includes `server.version` (the running dashboard release). Pair with `ccam version` or the Settings About panel to confirm client and server builds match after deploy.
```mermaid
sequenceDiagram
participant Client
participant API
participant Auth
participant Resource
Client->>API: Request + DASHBOARD_TOKEN
API->>Auth: Validate token (if configured)
Auth-->>API: Valid
API->>Resource: Fetch Data
Resource-->>API: Return Data
API-->>Client: 200 OK + Data
```
---
## Base URL
```
http://localhost:4820
```
For production, use HTTPS:
```
https://dashboard.example.com
```
---
## REST API
### Lanes
Lane mutations (`POST /api/lanes/ensure`, `POST /api/lanes/worktree`,
`PATCH /api/lanes/:id`, confirmed
actions, the runtime routes `POST /api/lanes/:id/up`, `/down` and
`/hook/:name`, and `DELETE /api/lanes/:id`) enforce the same loopback same-origin
guard as `/api/run`: browser requests must originate from `localhost`,
`127.0.0.1`, `::1`, or `0.0.0.0`; CLI and curl requests without `Origin` pass.
Every lane object returned by `GET /api/lanes` and `GET /api/lanes/:id`
additionally carries the server's stage-detection heuristic (see
`docs/LANES.md#stage-detection` for the full rules, and
`server/lib/stage-detect.js` for the matcher):
- **`detected_stage`** — stage id inferred from ingested tool events, or
`null` if no signal has been seen. Independent of the agent's own declared
`stage`.
- **`detected_signal`** — the capped (≤120 char) tool-event string that
produced `detected_stage`; `null` when `detected_stage` is `null`.
- **`detected`** (boolean, on each entry of `pipeline_nodes`) — `true` for the
inferred node and any node before it that carries no declared record.
Decorates that node's existing `state`; **never** upgrades it to `done` —
an inferred stage is never evidence.
#### Find or adopt the lane owning a directory
```http
POST /api/lanes/ensure
```
```json
{ "cwd": "/absolute/path/to/work", "title": "App package", "pipeline": "ship-feature" }
```
`cwd` is optional — when missing or empty the server falls back to
`os.homedir()` (the user's home directory). This is how the Sagi mascot
adopts its dedicated lane without the client needing to know an absolute
path. Existing cwd values still pass through unchanged. `pipeline` is
optional and, like `title`, applies **only when a lane is actually created**
— an existing lane is returned untouched. Omitted, the lane gets `default`.
`POST /api/lanes/worktree` and `POST /api/lanes` take the same optional
field. An id no template defines returns `400 EBADPIPELINE`.
Idempotent lookup by working directory — the Workspace page opens on a `cwd`,
not on a lane id. Returns the lane whose own `cwd` is that path, or the longest
path-boundary parent of it, as `{ "lane": {...}, "created": false }` with `200`.
When no lane owns the path, an `adopted` lane is created and returned as
`{ "lane": {...}, "created": true }` with `201`; `title` is used only in that
case. A *relative* `cwd` returns `400 EBADCWD`; a missing or empty `cwd`
falls back to the user's home directory instead of erroring. Path boundaries
are respected: a lane at `/tmp/wt` owns `/tmp/wt/pkg` but never `/tmp/wt-sibling`.
Two concurrent calls for the same path always yield **one** lane: the
`lanes.cwd` UNIQUE constraint decides the winner, and the loser re-reads and
returns the winning lane rather than reporting a conflict.
#### Update a lane
```http
PATCH /api/lanes/:id
```
Partially updates a lane. This route is same-origin guarded because its patch
can set `run_id`.
`pipeline` is patchable, which is how a lane moves between templates after
creation (`ccam lanes pipeline <template-id>`). An id no template defines is
rejected with `400 EBADPIPELINE` rather than stored: `getPipeline` falls back
to the default template when READING, so an unvalidated write would be
accepted and then silently render the wrong map forever. Same 400 treatment as
`EBADKIND`. Switching a template re-resolves the lane's existing declared
`stage` string against the new node list — a stage the old template knew may
resolve to nothing in the new one, leaving `progress: 0` and no `current` node
until the next `ccam stage`.
#### Provision a managed worktree
```http
POST /api/lanes/worktree
```
Creates a dashboard-owned lane from an existing absolute git repository. The
request returns immediately with `202` and `lane.status: "provisioning"`; the
dashboard completes the `git worktree add` in the background and broadcasts the
existing `lane_update` message when the lane becomes `idle` or `failed`.
```json
{
"sourceRepo": "/absolute/path/to/repository",
"title": "Criteria form",
"base": "main"
}
```
`sourceRepo` must exist, be absolute, and be a git repository. When `base` is
omitted, it defaults to the `LANE_BASE_BRANCH` env var, or `main` when that is
also unset. The feature branch is named `<LANE_BRANCH_PREFIX><slug>` (prefix
defaults to `feat/`). `title` (or an optional `slug`) determines the
safe branch and directory suffix. If that directory already exists, the slug is
suffixed with `-2`, `-3`, and so on, up to 50 attempts. A computed cwd that
already belongs to another lane returns `409 EDUPCWD`; exhausting the directory
attempts returns `409 EWORKTREEDIRCOLLISION`. A failed provision keeps its
managed lane row with the git diagnostic in `notes`; use `DELETE /api/lanes/:id`
to forget that row because no worktree exists to remove.
#### Preflight a destructive lane action
```http
GET /api/lanes/:id/preflight?action=reset|remove|purge
```
Returns the exact facts a user must confirm. `reset` and `remove` return
`head`, `dirty`, `untracked`, `unpushed`, `database`, `blocked`, and `warnings`;
`purge` returns `sessions`, `events`, `tokenRows`,
`bytesEstimate`, and `activeSessionSkipped`. `expect` is required: reset/remove
must include all of `head`, `dirty`, `untracked`, and `unpushed` (not `database`
— see below); purge must
include `sessions`, `events`, and `tokenRows`. Missing or incomplete
confirmation facts return `400 EEXPECT`.
`database` is the name a `reset` (unless `keepDb: true`) or `remove` will drop
— `null` when the lane has no slot yet or its profile declares no `DB_PREFIX`.
It is not part of the staleness-checked `expect` set: it is derived from the
lane's slot and profile, not from mutable git/session state, so it cannot go
stale between preflight and the action. See
`docs/LANES.md#data-isolation-database-redis-and-env-a2`.
`blocked` reports conditions that affect the action: `adopted`
(not a managed worktree), `missing` (directory gone), `unreadable` (directory
exists but git failed against it), and `unpushed-commits` (unpushed count > 0
— the only one `force: true` overrides). **Which of them actually prevent the
action depends on the action.** They all block `reset`. None of them blocks
`remove`: the server forgets an adopted lane's row, prunes a hand-deleted
worktree, and for an unreadable one attempts `git worktree remove --force` —
if git itself refuses (e.g. a corrupt worktree `.git` pointer), the server
deregisters it directly from the source repo's bookkeeping instead, without
ever touching the directory. So for `remove` they are context rather than
refusals — a client must not disable confirmation on them.
`warnings` holds purely informational
facts that never gate the action: currently just `no-remote` (no git remote
configured — nothing here is backed up remotely, but the action proceeds).
`unpushed` counts the commits the action would actually discard: commits on no
remote when remotes exist, otherwise the commits ahead of the lane's
`base_branch` (its own work), falling back to the total commit count only for a
lane with no base at all. A freshly provisioned worktree therefore reports `0`,
not the whole repository's history.
#### Reset, remove, or purge a lane
```http
POST /api/lanes/:id/reset
POST /api/lanes/:id/remove
POST /api/lanes/:id/purge
```
All three actions require `{ "confirm": true }` and a complete `expect` object,
and run under the lane lock. They stop the lane run and wait for the spawned
child's actual `exit` event (including the SIGKILL escalation window) before
performing the action. A failed spawn is treated as already exited because no
child process started or can touch the lane directory. A child that does not
exit returns `500 ERUNTIMEOUT` and does not run git. `reset` and `remove` additionally require
`{ "force": true }` when preflight reports `unpushed > 0`.
```json
{
"confirm": true,
"force": true,
"keepDb": false,
"expect": {
"head": "9b3e74a",
"dirty": 4,
"untracked": 11,
"unpushed": 2
}
}
```
For `purge`, `expect` contains `sessions`, `events`, and `tokenRows` from its
preflight. If any echoed fact changed, the action returns `409 ESTALE` with its
`expected` and `current` diagnostics, without resetting, removing, or purging.
Missing `force` for unpushed work returns `409 EUNPUSHED`.
`reset` restores the managed worktree's feature branch from its base, cleans
untracked files but preserves ignored files, clears the lane stage state, and —
when the lane's profile declares data isolation (`docs/LANES.md#data-isolation-database-redis-and-env-a2`)
— refreshes `.env`, re-runs `bootstrap`, clears `LANE_DIRS`, and drops +
recreates + migrates + reseeds the database, unless `keepDb: true` is passed
(reset only; skips that whole block, leaving the database untouched).
`remove` removes the managed git worktree, prunes it, deletes its feature branch,
drops the lane's database and its `_test` sibling (best-effort; never for an
adopted lane), then deletes the lane row. `purge` returns the deleted counts:
```json
{
"ok": true,
"purged": { "sessions": 2, "events": 14, "tokenRows": 2 }
}
```
Only dashboard-managed worktrees can be reset. For `remove`, a managed lane
uses guarded worktree removal and branch deletion; an adopted lane only has its
dashboard row forgotten, and its directory is never changed. `400` preserves
`ENOTMANAGED`, `EOUTSIDEROOT`, and `ENOTWORKTREE` guard failures on the managed
destroy path. Git failures return `500` with their git `stderr` in `error.stderr`.
#### Read a lane's runtime
```http
GET /api/lanes/:id/runtime
```
What is actually running for this lane, recomputed on every call from pid files
and port probes — never cached, because a process can die without telling
anyone. See `docs/LANES.md#lane-runtime-running-a-lanes-own-stack`.
```json
{
"available": true,
"provisioned": true,
"slot": 3,
"kind": "managed",
"hooks": ["bootstrap", "boot", "health"],
"profileDir": "/work/myapp/.ccam/profile",
"services": [{ "name": "api", "pid": 40213, "alive": true }],
"ports": { "api": { "port": 8103, "expected": 8003, "listening": true } },
"database": { "name": "myapp_l3", "testName": "myapp_l3_test" },
"redisIndex": 3,
"steppedAside": true,
"up": true,
"healthy": true,
"logs": ["boot.log", "health.log", "api.log"],
"logDir": "/home/you/.claude/ccam-lanes/.state/lane3/logs",
"lastError": null
}
```
- `database` is `null` when the profile declares no `DB_PREFIX`; `redisIndex`
is `null` when `REDIS` is not `1`. Both are names/indices only — never a
connection string, so this endpoint never leaks the `~/.ccam/secrets.env`
password even though it derives `DATABASE_URL` internally to run hooks.
- A lane whose repository declares no `.ccam/profile` returns
`{"available": false, "searched": [...]}` with HTTP **200** — the same
contract as `GET /:id/git`. Most lanes never run a stack; that is a normal
state, not a fault.
- A lane with a profile but no slot yet returns `{"available": true,
"provisioned": false, "hooks": [...], "ports": {}}`.
- `expected` is `PORT_BASE_<name> + slot`; when `port` differs, the allocator
stepped aside from a number already in use and `steppedAside` is `true`.
- Not folded into `GET /api/lanes` on purpose: it opens a socket per declared
port and stats every pid file, and the lane list is polled and re-broadcast on
every hook.
#### Boot or stop a lane's stack
```http
POST /api/lanes/:id/up { "build": true }
POST /api/lanes/:id/down
```
`up` returns **202** and boots in the background, because a build can run for
minutes. Progress streams as `lane_hook_output` WebSocket messages and the
attempt finishes with a `lane_runtime` message carrying the fresh facts (or an
`error`). `build: false` passes `--no-build` to the `boot` hook.
`up` runs the profile's `boot` then `health` hooks; it does **not** run
`bootstrap`. A failing `health` leaves the processes running so their logs
remain readable, and records `EUNHEALTHY` in the runtime's `lastError`.
`down` is synchronous, idempotent, and a no-op for a lane that was never up:
```json
{ "ok": true, "killed": [40213, 40219], "runtime": { "…": "…" } }
```
Both write only `slot` and `ports` on the lane row. Neither writes `stage`,
`status`, or `notes` — in CCAM those describe the agent's work, not the stack's
state. Adopted lanes may be brought up and down.
Errors: `400 ENOPROFILE` (with the paths searched), `409 ESLOTS` (every slot
taken), `409 EPORTBUSY` (with `port` and the occupying `pids`), `404 ENOLANE`.
#### Run a profile hook
```http
POST /api/lanes/:id/hook/:name { "args": ["--scope", "smoke"] }
```
Runs one of the profile's hooks — the surface a driving session uses for
`ci-gate`, `e2e`, `migrate` and friends. Returns **202**; output streams as
`lane_hook_output` and completion arrives as `lane_hook_result` with the exit
`code`.
`:name` is checked against a fixed allowlist (`bootstrap`, `boot`, `health`,
`migrate`, `seed`, `ci-gate`, `e2e`, `regen`, `db-create`, `db-drop`) **before**
anything is spawned, and `args` travels as an array of strings straight into
argv — neither is ever joined into a command string. An unknown name returns
`400 ENOHOOK` with the allowed list; a lane with no slot returns `409 ENOSLOT`.
#### Tail a lane's log
```http
GET /api/lanes/:id/logs/:svc?tail=65536
```
```json
{ "available": true, "svc": "api", "size": 20481, "truncated": false, "text": "…" }
```
`tail` is the number of trailing bytes (default 64 KiB, capped at 1 MiB). The
resolved path is confined to the lane's log directory after `realpath`, so a
name from the request can never escape it; anything else is `404 ENOLOG`. A lane
with no slot returns `{"available": false}`.
#### Lane features
Per-feature state and archive (`server/lib/lane-features.js`) — a lane's
`stage`/`status`/etc. is always the LIVE view of whichever feature it's
currently on; these routes read and switch between a lane's feature history.
```http
GET /api/lanes/:id/features
```
Every feature this lane has ever activated, archived or live, most recently
touched first:
```json
{
"features": [
{
"id": 5, "lane_id": 12, "slug": "auth-redesign", "title": "auth-redesign",
"branch": "feat/auth", "pipeline": "default", "stage": "implement",
"stage_since": "2026-08-04T09:00:00.000Z", "status": "running",
"gate_decision": null, "ci_status": null, "qc_dev": null,
"stages": {}, "links": {}, "notes": null, "archived_at": null,
"created_at": "2026-08-04T09:00:00.000Z", "updated_at": "2026-08-04T09:00:00.000Z",
"pipeline_name": "Default", "pipeline_nodes": [ /* same shape as a live lane's */ ], "progress": 40
}
]
}
```
Each row is the hydrated `lane_features` row (`stages`/`links` parsed from
JSON) plus `pipeline_name`/`pipeline_nodes`/`progress`, computed the exact
same way a live lane's are — the client renders an archived feature with the
same `PipelineMap` component, no special-casing. `archived_at` is `null` for
the currently-active feature, an ISO timestamp for every past one. `slug` is
always the **canonicalized** form (drops a leading `feat/`, turns `/` and
whitespace into `-`, keeps `[A-Za-z0-9._-]`, preserves case — **not the same
rule as `worktree.js:slugify`**, which lowercases everything).
```http
GET /api/lanes/:id/features/:slug
```
One feature by its canonicalized slug, same shape as an entry above, wrapped
as `{ "feature": {...} }`. `404 ENOFEATURE` if that slug was never activated
on this lane; `404 ENOLANE` if the lane itself doesn't exist.
```http
POST /api/lanes/:id/features/activate
{ "slug": "feat/Auth Redesign", "title": "Auth redesign (optional)" }
```
Switches the lane to a feature by slug:
- If a **different** feature is currently active, it is archived first with
its exact live state (stage, status, gate/CI fields, stages, links, notes)
— its own `title` is never overwritten by this.
- If the target slug was activated before, its saved state is restored onto
the live lane (stage, status, gate/CI fields, stages, links, notes) — this
is what makes switching back to a past feature resume where it left off.
- If the slug is new, a fresh feature row is created (`title` defaults to the
slug itself when omitted).
- Re-activating the **currently** active slug is a no-op.
Response: `{ "lane": <same shape GET /api/lanes/:id returns>, "feature": <same shape as the list above> }`.
- **200** — activated (or already active).
- **400** `{ "error": { "code": "EBADSLUG", "message": "slug is required" } }` — missing/empty `slug`, or the canonicalized result is empty (e.g. `"feat/"` alone).
- **404** `{ "error": { "code": "ENOLANE", "message": "lane not found" } }`.
#### Lane proof gallery
Proof gallery (QC screenshots) captured at `<lane.cwd>/.playwright-mcp/proof/<slug>/<phase-group>/` and grouped by feature slug (the same slug from per-feature state). Read-only access and deletion are path-contained: every proof file is resolved, realpath'd, and checked to be inside the lane's proof root before serving or deleting.
```http
GET /api/lanes/:id/proof
```
List every feature with proof (or an activated-but-not-yet-shot feature from `lane_features`), most recently touched first:
```json
{
"features": [
{
"slug": "auth-redesign",
"groups": {
"desktop": ["01-login.png", "02-signup.png"],
"mobile": ["01-login.png"]
},
"ticket_report": "auth-redesign/ticket/REPORT.html",
"mtime": 1722755400
}
]
}
```
Each feature has:
- **`slug`** (string) — canonicalized feature slug
- **`groups`** (object) — `{ "phase": ["image1.png", "image2.png"] }` — phase-groups with their images
- **`ticket_report`** (string) — relative path to ticket report HTML, or empty if none exists
- **`mtime`** (number) — Unix seconds of the most recent file touched in this feature's proof tree (0 if the feature has no proof yet)
**Status codes:**
- **200** — success
- **404** `{ "error": { "code": "ENOLANE", "message": "lane not found" } }` — lane doesn't exist
```http
GET /api/lanes/:id/proof/:slug/:group/:file
```
Fetch a single proof file (PNG, JPG, or HTML). The file is resolved with path containment checks before serving:
**Status codes:**
- **200** — file content, `Content-Type` set to `image/png`, `image/jpeg`, or `text/html`
- **404** — lane, slug, group, or file not found, or path escapes containment
```http
DELETE /api/lanes/:id/proof/:slug
```
Delete proof at one of three granularities: request body `{ "group": "phase-name", "images": ["file1.png"] }` deletes specific images from a phase, `{ "group": "phase-name" }` deletes an entire phase, or empty `{}` deletes all phases **except** `ticket/`.
```json
{ "deleted": 3 }
```
**Status codes:**
- **200** — success, returns count of deleted files
- **400** `{ "error": { "code": "EBADPATH", "message": "…" } }` — invalid slug, group, or image name; or attempt to delete `ticket/`
- **404** `{ "error": { "code": "ENOLANE", "message": "lane not found" } }` — lane doesn't exist
- **404** `{ "error": { "code": "ENOFEATURE", "message": "…" } }` — slug doesn't exist
```http
POST /api/lanes/:id/proof-link
```
Converge the clone-root `proof/` directory (if it exists at `<lane.cwd>/proof`) onto the canonical `.playwright-mcp/proof`. This is an explicit operation, never automatic — exposed only as `ccam lanes proof-link`. Idempotent.
**Response:**
```json
{ "linked": true }
```
- **`linked`** (boolean) — `true` if the link was created or fixed, `false` if the symlink already pointed to the right place or a collision was avoided
**Status codes:**
- **200** — success
- **404** `{ "error": { "code": "ENOLANE", "message": "lane not found" } }` — lane doesn't exist
#### Pipeline pass statistics
```http
GET /api/lanes/pipeline-stats
```
How often each pipeline node was actually passed, aggregated over every lane
**and** every archived feature (an archived feature is a finished run of a
pipeline, so it carries most of the history once a lane starts its next
feature). Grouped by pipeline template, busiest first.
**Query parameters:**
- **`lane`** — lane id; narrows the aggregation to that lane and its own
archived features. Omit (or pass `all`) for every lane.
```json
{
"pipelines": [
{
"id": "default",
"name": "Default feature pipeline",
"runs": 4,
"nodes": [
{ "id": "plan", "label": "plan", "icon": "🧭", "gate": false,
"entered": 4, "passed": 4, "withEvidence": 4, "failed": 0 }
]
}
],
"lanes": [{ "id": 1, "title": "api lane", "cwd": "/w/api" }]
}
```
- **`lanes`** — every lane, for the picker. Never narrowed by `lane`, so the
response always offers the way back to the other lanes.
- **`runs`** — lanes plus archived features recorded against this template.
- **`entered`** — stage records that resolve onto this node (aliases included).
- **`failed`** — records whose `result` is `"fail"`; **`passed`** is the rest,
so `passed + failed === entered`.
- **`withEvidence`** — the subset of passes that carry evidence. Tracked apart
from `passed` because a claim and a proof are not the same fact — the same
distinction the pipeline map draws between `done` and `passed-no-evidence`.
**Status codes:**
- **200** — success (an empty `pipelines` array when no lane has recorded a
stage yet, or when `lane` names an id that no longer exists)
- **400** `{ "error": { "code": "EBADLANE", "message": "lane must be an id" } }` — `lane` is not a positive integer
### Workflows
#### Skill and plugin usage
```http
GET /api/workflows/skill-usage?status=active|completed
```
Skill invocations counted from `Skill` tool events — `PreToolUse` rows only,
since every call also emits a `PostToolUse` and counting both would double
every number. `status` filters by session lifecycle, same convention as
`GET /api/workflows`; omit it (or pass `all`) for no filter.
```json
{
"skills": [
{ "name": "ccam:ship-feature", "plugin": "ccam", "count": 12, "sessions": 5 },
{ "name": "daily-report", "plugin": null, "count": 3, "sessions": 3 }
],
"plugins": [
{ "name": "ccam", "skills": 1, "skillCalls": 12, "mcpCalls": 4 }
]
}
```
- **`skills`** — top 30 by call count. `plugin` is the `plugin:skill` prefix, or
`null` for a built-in or personal skill.
- **`plugins`** — per-plugin totals: distinct skills used, their calls, and
calls to the MCP tools that plugin ships (`mcp__plugin_<plugin>_<server>__*`),
so a plugin used only through MCP does not report zero.
**Status codes:** **200** always; **500** with `{ "error": { "message" } }` on a
query failure.
### Locks
Cross-lane named locks (`server/lib/named-lock.js`) — the OTHER axis from a
lane's own internal serialization: a name held by one lane at a time, across
ALL lanes, for a step that thrashes the shared machine (a build, an e2e run).
Every route here is **single-shot** — it never blocks waiting for a lock to
free up. A caller that wants to wait polls `POST /:name/acquire` itself (the
`ccam lock acquire` CLI does exactly this, printing a status line every ~60s
of continued waiting); the server never orchestrates that loop.
#### List locks
```http
GET /api/locks
```
Every currently-held lock:
```json
{
"locks": [{ "name": "build", "holder": "lane3", "since": 1722702012, "ageSec": 41 }]
}
```
`since` is a Unix **seconds** timestamp; `ageSec` is derived at read time. An
empty array means nothing is held.
#### Acquire a lock
```http
POST /api/locks/:name/acquire
{ "holder": "lane3" }
```
`holder` is **required** — this route does not default it; `ccam lock
acquire` resolves `lane<slot>` for the calling lane and sends it explicitly.
A holder older than `LOCK_MAX_HOLD` (default 2700s, floored at 300s — see
`docs/LANES.md#cross-lane-named-locks`) is broken automatically before the
attempt.
- **200** `{ "acquired": true }` — lock claimed.
- **409** `{ "acquired": false, "holder": "lane1", "since": 1722701900, "ageSec": 150 }` — already held by someone else (fresh enough not to be broken).
- **400** `{ "error": { "code": "EBADHOLDER", "message": "holder is required" } }` — missing/empty `holder`.
#### Release a lock
```http
POST /api/locks/:name/release
{ "holder": "lane3" }
```
- **200** `{ "ok": true }` — released.
- **409** `{ "error": { "code": "ENOTHOLDER", "message": "...", "currentHolder": "lane1" } }` — `holder` doesn't match the current owner; a release never trusts its caller, same rule every other destructive path in this project follows.
- **404** `{ "error": { "code": "ENOLOCK", "message": "no such lock: build" } }` — nothing by that name is held.
### Sessions
#### List Sessions
```http
GET /api/sessions
```
Returns all sessions, ordered by most recent activity.
**Query Parameters:**
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `limit` | integer | 50 | Maximum sessions to return (1-1000) |
| `offset` | integer | 0 | Pagination offset |
| `status` | string | - | Filter by persisted status: `active`, `completed`, `error`, `abandoned`. The UI **Waiting** state is derived from the `awaiting_input_since` column and is not a queryable enum — filter `status=active` and inspect `awaiting_input_since` (non-null = Waiting) |
| `sources` | string | - | Comma-separated data-source ids to include (the built-in local history is `local`; remote SSH machines use their `remote_sources.id`). Omit for all sources. Also accepted on `/api/events`, `/api/agents`, `/api/stats`, `/api/analytics`, and `/api/pricing/cost`. See [Remote Data Sources](#remote-data-sources) |
**Example Request:**
```bash
curl http://localhost:4820/api/sessions?limit=10&status=active
```
**Example Response:**
```json
{
"sessions": [
{
"id": 1,
"session_id": "sess_abc123",
"model": "claude-sonnet-4",
"status": "active",
"total_cost": 1.23,
"agent_count": 3,
"tool_count": 12,
"created_at": "2024-03-18T12:00:00Z",
"updated_at": "2024-03-18T14:30:00Z"
}
],
"total": 42,
"limit": 10,
"offset": 0
}
```
**Response Schema:**
```mermaid
classDiagram
class SessionListResponse {
+Session[] sessions
+number total
+number limit
+number offset
}
class Session {
+string id
+string name
+string status "active|completed|error|abandoned"
+string cwd
+string model
+string started_at
+string ended_at
+string updated_at
+string awaiting_input_since "null unless Waiting"
+string awaiting_reason "notification|stop|session_start|interrupted; null unless Waiting"
+number cost
+number agent_count
}
SessionListResponse --> Session
```
---
#### Get Session
```http
GET /api/sessions/:id
```
Returns single session details.
**Path Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | Session ID (e.g., `sess_abc123`) |
**Example Request:**
```bash
curl http://localhost:4820/api/sessions/sess_abc123
```
**Example Response:**
```json
{
"session": {
"id": 1,
"session_id": "sess_abc123",
"model": "claude-sonnet-4",
"status": "active",
"total_cost": 1.23,
"created_at": "2024-03-18T12:00:00Z",
"updated_at": "2024-03-18T14:30:00Z"
}
}
```
**Error Responses:**
| Code | Description |
|------|-------------|
| 404 | Session not found |
| 500 | Internal server error |
---
#### Get Session Stats
```http
GET /api/sessions/:id/stats
```
Returns aggregated counts powering the Session Detail overview panel. All aggregation runs in SQL — the response is cheap to compute even for sessions with tens of thousands of events.
**Path Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | Session ID |
**Example Request:**
```bash
curl http://localhost:4820/api/sessions/sess_abc123/stats
```
**Example Response:**
```json
{
"session_id": "sess_abc123",
"total_events": 14082,
"events_by_type": [
{ "event_type": "PreToolUse", "count": 5210 },
{ "event_type": "PostToolUse", "count": 5208 }
],
"tools_used": [
{ "tool_name": "Bash", "count": 1842 },
{ "tool_name": "Read", "count": 1340 }
],
"error_count": 12,
"first_event_at": "2026-04-26T18:59:00.000Z",
"last_event_at": "2026-04-29T21:30:14.000Z",
"agents": {
"total": 12,
"main": 1,
"subagent": 11,
"compaction": 5,
"by_status": { "completed": 11, "working": 1 }
},
"subagent_types": [
{ "subagent_type": "Explore", "count": 4 }
],
"tokens": {
"input_tokens": 1376,
"output_tokens": 760304,
"cache_read_tokens": 337641891,
"cache_write_tokens": 5126047
}
}
```
**Error Responses:**
| Code | Description |
|------|-------------|
| 404 | Session not found |
| 500 | Internal server error |
---
#### Get Session Agents
```http
GET /api/sessions/:id/agents
```
Returns all agents for a session.
**Path Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | Session ID |
**Example Request:**
```bash
curl http://localhost:4820/api/sessions/sess_abc123/agents
```
**Example Response:**
```json
{
"agents": [
{
"id": "sess_abc123-main",
"session_id": "sess_abc123",
"name": "Main Agent - my-project",
"type": "main",
"subagent_type": null,
"status": "idle",
"current_tool": null,
"task": null,
"started_at": "2024-03-18T12:00:00Z",
"ended_at": null,
"updated_at": "2024-03-18T12:05:00Z",
"parent_agent_id": null,
"awaiting_input_since": "2024-03-18T12:05:00Z",
"awaiting_reason": "stop",
"cost": 0
}
]
}
```
> **Note on `cost`** — `/api/agents` and `/api/sessions/:id/agents` attach a `cost` (USD) to each agent: the agent's **own** cost, computed server-side from the per-agent token buckets stored in `agents.metadata.tokens` and priced at the current pricing rules (at the agent's start date, so promo/standard cutovers apply — see [Pricing](#pricing)). It is `0` for main agents (whose cost is the session total, reported by `/api/pricing/cost/:sessionId`), for compaction pseudo-agents, and for any subagent whose transcript is unavailable. This lets a subagent card show only what that subagent spent instead of the whole session's total.
> **Note on `status` vs Waiting** — agents are persisted with one of `idle | connected | working | completed | error`. The yellow **Waiting** badge surfaced in the dashboard is a UI overlay derived from `awaiting_input_since` being non-null on a non-terminal agent (typically `idle` after a `Stop`, or `connected` right after `SessionStart`). Filter `?status=idle` on `/api/agents` and inspect `awaiting_input_since` to enumerate currently-waiting main agents.
---
### Agents
#### Get Agent
```http
GET /api/agents/:id
```
Returns single agent details.
**Path Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | Agent ID (e.g., `agent_xyz789`) |
**Example Request:**
```bash
curl http://localhost:4820/api/agents/agent_xyz789
```
**Example Response:**
```json
{
"agent": {
"id": 1,
"agent_id": "agent_xyz789",
"session_id": "sess_abc123",
"agent_type": "explore",
"status": "completed",
"current_tool": null,
"input_tokens": 1500,
"output_tokens": 800,
"cost": 0.45,
"created_at": "2024-03-18T12:00:00Z",
"updated_at": "2024-03-18T12:05:00Z"
}
}
```
---
#### Get Agent Tools
```http
GET /api/agents/:id/tools
```
Returns tool executions for an agent.
**Path Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | Agent ID |
**Example Request:**
```bash
curl http://localhost:4820/api/agents/agent_xyz789/tools
```
**Example Response:**
```json
{
"tools": [
{
"id": 1,
"agent_id": "agent_xyz789",
"tool_name": "bash",
"duration_ms": 1234,
"success": 1,
"error_message": null,
"created_at": "2024-03-18T12:01:00Z"
},
{
"id": 2,
"agent_id": "agent_xyz789",
"tool_name": "view",
"duration_ms": 45,
"success": 1,
"error_message": null,
"created_at": "2024-03-18T12:02:00Z"
}
]
}
```
**Tool Execution Flow:**
```mermaid
sequenceDiagram
participant Agent
participant PreHook as PreToolUse Hook
participant Tool as Tool Execution
participant PostHook as PostToolUse Hook
participant DB as Database
Agent->>PreHook: Tool about to execute
PreHook->>DB: Set current_tool
Agent->>Tool: Execute (bash, view, etc.)
Tool-->>Agent: Result
Agent->>PostHook: Tool completed
PostHook->>DB: Create tool_execution record
PostHook->>DB: Clear current_tool
PostHook->>DB: Update token counts + cost
```
---
### Tools
#### List All Tools
```http
GET /api/tools
```
Returns all tool executions across all sessions.
**Query Parameters:**
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `limit` | integer | 100 | Max tools to return |
| `tool_name` | string | - | Filter by tool name |
| `success` | boolean | - | Filter by success status |
**Example Request:**
```bash
curl http://localhost:4820/api/tools?limit=50&tool_name=bash
```
**Example Response:**
```json
{
"tools": [
{
"id": 1,
"agent_id": "agent_xyz789",
"tool_name": "bash",
"duration_ms": 1234,
"success": 1,
"error_message": null,
"created_at": "2024-03-18T12:01:00Z"
}
],
"total": 156
}
```
---
### Metrics
#### Prometheus exposition
```
GET /api/metrics
```
Exposes the dashboard's live counters in the [Prometheus text-exposition format](https://prometheus.io/docs/instrumenting/exposition_formats/) (v0.0.4) so this monitoring dashboard can itself be scraped into Prometheus / Grafana. Read-only. Values are read from the same prepared statements the REST API uses, so they match the UI.
Response `Content-Type: text/plain; version=0.0.4; charset=utf-8`.
| Metric | Type | Labels | Meaning |
| --- | --- | --- | --- |
| `ccam_up` | gauge | — | `1` when the API served the scrape |
| `ccam_build_info` | gauge | `version` | Always `1`; dashboard version rides on the label |
| `ccam_process_uptime_seconds` | gauge | — | Server process uptime |
| `ccam_process_resident_memory_bytes` | gauge | — | Server process RSS |
| `ccam_sessions` | gauge | `status` (`active`/`completed`/`error`/`abandoned`) | Sessions by status |
| `ccam_agents` | gauge | `status` (`working`/`waiting`/`completed`/`error`) | Agents by status |
| `ccam_events_total` | counter | — | Total events recorded |
| `ccam_websocket_clients` | gauge | — | Connected realtime clients |
| `ccam_remote_sources` | gauge | `enabled` (`true`/`false`) | Configured Remote Data Sources |
| `ccam_tokens_total` | counter | `kind` (`input`/`output`/`cache_read`/`cache_write`) | Cumulative token usage |
Status series are always emitted (even at `0`) so a series never disappears from the exposition. The endpoint is mounted under `/api`, so it sits behind the same two guards as every other route: the **Host-header (DNS-rebinding) guard** and the optional **`DASHBOARD_TOKEN`** guard. A scraper that reaches the server as anything other than loopback (e.g. Prometheus in Docker hitting `host.docker.internal`) must be allowlisted with `DASHBOARD_ALLOWED_HOSTS`, or the scrape returns `403 EBADHOST`; if a token is set, the scrape must also send it.
Example scrape config (start the server with `DASHBOARD_ALLOWED_HOSTS=host.docker.internal`):
```yaml
scrape_configs:
- job_name: ccam
metrics_path: /api/metrics
static_configs:
- targets: ["host.docker.internal:4820"]
# authorization: # only if DASHBOARD_TOKEN is set
# credentials: "<DASHBOARD_TOKEN>"
```
---
### Pricing
#### List Pricing Rules
```http
GET /api/pricing
```
Returns all pricing rules (default + custom).
**Example Request:**
```bash
curl http://localhost:4820/api/pricing
```
**Example Response:**
```json
{
"rules": [
{
"id": 1,
"pattern": "claude-sonnet-4",
"input_cost_per_1m": 3.0,
"output_cost_per_1m": 15.0,
"is_default": true,
"created_at": "2024-03-18T12:00:00Z"
},
{
"id": 10,
"pattern": "gpt-5.1-codex",
"input_cost_per_1m": 2.5,
"output_cost_per_1m": 10.0,
"is_default": false,
"created_at": "2024-03-18T14:30:00Z"
}
]
}
```
**Pricing Rule Matching:**
```mermaid
graph TB
Model[Model Name<br/>e.g., claude-sonnet-4] --> Match{Match Pattern?}
Match -->|Exact Match| Custom[Use Custom Rule]
Match -->|Substring Match| Default[Use Default Rule]
Match -->|No Match| Fallback[Use Generic Fallback]
Custom --> Calculate[Calculate Cost]
Default --> Calculate
Fallback --> Calculate
Calculate --> Result[input_cost + output_cost]
style Calculate fill:#10B981
```
---
#### Create or Update Pricing Rule
```http
PUT /api/pricing
```
Upsert a pricing rule, keyed by `model_pattern`. The same call creates a new rule or updates an existing one (matched on `model_pattern`). Rates are per **million** tokens.
**Request Body:**
```json
{
"model_pattern": "claude-sonnet-5%",
"display_name": "Claude Sonnet 5",
"input_per_mtok": 3,
"output_per_mtok": 15,
"cache_read_per_mtok": 0.3,
"cache_write_per_mtok": 3.75,
"cache_write_1h_per_mtok": 6,
"fast_input_per_mtok": 0,
"fast_output_per_mtok": 0,
"intro_until": "2026-08-31",
"intro_input_per_mtok": 2,
"intro_output_per_mtok": 10,
"intro_cache_read_per_mtok": 0.2,
"intro_cache_write_per_mtok": 2.5,
"intro_cache_write_1h_per_mtok": 4
}
```
**Fields:**
| Field | Type | Constraints |
|-------|------|-------------|
| `model_pattern` | string | Required. SQL-style glob; `%` matches any characters (e.g. `claude-opus-4-7%`) |
| `display_name` | string | Required |
| `input_per_mtok` / `output_per_mtok` | number | Standard per-MTok rates (default 0) |
| `cache_read_per_mtok` / `cache_write_per_mtok` / `cache_write_1h_per_mtok` | number | Cache rates (default 0) |
| `fast_input_per_mtok` / `fast_output_per_mtok` | number | Fast-mode premium rates (default 0) |
| `intro_until` | string \| null | Optional promo cutoff `YYYY-MM-DD`. Usage **on or before** this date is priced at the `intro_*` rates, after it at the standard rates. Empty/`null` clears the promo (and zeroes the intro rates) |
| `intro_*_per_mtok` | number | Optional introductory (promo) rates, mirroring the standard fields |
The intro block is **optional and backward-compatible**: a request that omits every `intro_*`/`intro_until` field leaves any existing promo untouched, so older clients that send only the standard rates never clobber a promo.
**Validation:** every `*_per_mtok` rate present in the body must be a **non-negative finite number** (numeric strings are coerced); a `NaN`, non-numeric, or negative value is rejected with `400 INVALID_INPUT` naming the offending field, and nothing is written. `intro_until` must be a `YYYY-MM-DD` date (or empty/`null` to clear the promo).
**Example Request:**
```bash
curl -X PUT http://localhost:4820/api/pricing \
-H "Content-Type: application/json" \
-d '{
"model_pattern": "gpt-5.1-codex",
"display_name": "GPT-5.1 Codex",
"input_per_mtok": 2.5,
"output_per_mtok": 10.0
}'
```
**Example Response:**
```json
{
"pricing": {
"model_pattern": "gpt-5.1-codex",
"display_name": "GPT-5.1 Codex",
"input_per_mtok": 2.5,
"output_per_mtok": 10.0,
"intro_until": null,
"updated_at": "2026-07-01T14:30:00Z"
}
}
```
**Error Responses:**
| Code | Description |
|------|-------------|
| 400 | Missing `model_pattern`/`display_name`, or `intro_until` not a `YYYY-MM-DD` date |
| 500 | Database error |
---
#### Delete Pricing Rule
```http
DELETE /api/pricing/:pattern
```
Delete custom pricing rule (default rules cannot be deleted).
**Path Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `pattern` | string | Pattern to delete (URL-encoded) |
**Example Request:**
```bash
# Pattern must be URL-encoded
curl -X DELETE http://localhost:4820/api/pricing/gpt-5.1-codex
```
**Example Response:**
```json
{
"deleted": true
}
```
**Error Responses:**
| Code | Description |
|------|-------------|
| 404 | Pattern not found |
| 403 | Cannot delete default rule |
| 500 | Database error |
---
### Notifications
#### Get Session Notifications
```http
GET /api/sessions/:id/notifications
```
Returns notifications for a session.
**Path Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | Session ID |
**Example Request:**
```bash
curl http://localhost:4820/api/sessions/sess_abc123/notifications
```
**Example Response:**
```json
{
"notifications": [
{
"id": 1,
"session_id": "sess_abc123",
"notification_type": "backgroundTaskComplete",
"message": "Explore agent completed",
"created_at": "2024-03-18T12:05:00Z"
}
]
}
```
### Remote Data Sources
The `/api/remote-sources/*` namespace configures **remote SSH machines** the dashboard pulls Claude Code history from, so one dashboard can consolidate sessions from several machines. **No secrets are stored** — SSH authentication defers entirely to the host's SSH stack (ssh-agent, `~/.ssh/config`, key files). Every imported session is tagged with the source's id in the `sessions.source` column (the built-in local history uses the id `local`), which powers the `sources` filter below.
**RemoteSource shape:**
```json
{
"id": "4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11",
"label": "Work laptop",
"host": "son@studio.local",
"ssh_port": 22,
"identity_file": "~/.ssh/id_ed25519",
"remote_home": "~/.claude",
"enabled": true,
"status": "ok",
"last_error": null,
"last_sync_at": "2026-07-22T18:41:55.117Z",
"last_sync_counts": {
"imported": 9,
"skipped": 41,
"backfilled": 0,
"errors": 0,
"sessions_seen": 50,
"sessions_tagged": 50
},
"created_at": "2026-07-20T09:15:00.000Z",
"updated_at": "2026-07-22T18:41:55.117Z"
}
```
`ssh_port`, `identity_file`, `remote_home`, `last_error`, `last_sync_at`, and `last_sync_counts` are nullable. `status` is one of `idle`, `syncing`, `ok`, `error`.
#### List Remote Sources
```http
GET /api/remote-sources
```
Returns all configured remote sources. Response: `{ "sources": RemoteSource[] }`.
#### Create Remote Source
```http
POST /api/remote-sources
```
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `label` | string | Yes | Human-readable name |
| `host` | string | Yes | SSH destination (`user@host`) or a `~/.ssh/config` alias |
| `ssh_port` | integer | No | SSH port (defers to SSH default / config when omitted) |
| `identity_file` | string | No | Private-key path passed to ssh (`-i`) |
| `remote_home` | string | No | Remote Claude home (defaults to remote `~/.claude`) |
> **Cursor (informational):** Sessions imported from `~/.claude` include **Cursor** agent usage on that machine too — Cursor happens to use the same paths as Claude Code. CCAM does not tag which app created a session.
| `enabled` | boolean | No | Whether the source is eligible for syncs (default `true`) |
Returns `{ "source": RemoteSource }` with HTTP **201**.
**Error Responses (400):** `{ "error": { "code", "message" } }` with one of:
| Code | Meaning |
|------|---------|
| `INVALID_LABEL` | Missing/blank `label` |
| `INVALID_HOST` | Missing/invalid `host` |
| `INVALID_PORT` | `ssh_port` out of range |
| `INVALID_IDENTITY_FILE` | Invalid `identity_file` value |
| `INVALID_REMOTE_HOME` | Invalid `remote_home` value |
#### Update Remote Source
```http
PATCH /api/remote-sources/:id
```
Partial update — only the keys present in the body change. Same fields (and the same validation codes) as create; both `label` and `host` are optional here. Returns `{ "source": RemoteSource }`, or **404** if the id is unknown.
#### Delete Remote Source
```http
DELETE /api/remote-sources/:id
```
**Query Parameters:**
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `purge` | boolean | `false` | When `true`, also delete this source's imported sessions. When omitted/`false`, those sessions are **detached** — reassigned to the `local` source so history is preserved |
Returns `{ "ok": true, "purged": <bool> }` (`purged` is `true` only when `?purge=true` deleted the sessions). **404** if the id is unknown.
#### Test Remote Source
```http
POST /api/remote-sources/:id/test
```
Runs an SSH connectivity probe. Returns `{ "ok": <bool>, "message": <string>, "remoteProjects?": string[] }` — `remoteProjects` lists the discovered remote project directories on success. Does not import anything. **404** if the id is unknown.
#### Sync Remote Source
```http
POST /api/remote-sources/:id/sync
```
Pulls Claude Code history from the remote over SSH now, through the same idempotent import pipeline used locally, tagging imported sessions with this source's id. Progress/completion is also broadcast over the WebSocket as [`remote_source.status`](#remote_sourcestatus) frames.
**Example Response:**
```json
{
"ok": true,
"imported": 9,
"skipped": 41,
"backfilled": 0,
"errors": 0,
"sessions_seen": 50,
"sessions_tagged": 50
}
```
**404** if the id is unknown; **500** with `{ error: { code: "SYNC_FAILED", message } }` on SSH/import failure.
#### Sync All Remote Sources
```http
POST /api/remote-sources/sync-all
```
Pulls history from **every enabled** source sequentially (one SSH connection at a time). Per-source failures are isolated — one unreachable machine never aborts the others — and each outcome is returned in `results`. Always **200**.
**Example Response:**
```json
{ "ok": true, "synced": 2, "results": [{ "id": "src_a", "ok": true }, { "id": "src_b", "ok": false, "error": "ssh exited with code 255" }] }
```
#### The `sources` filter
`GET /api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, and `/api/analytics` accept an optional `sources` query parameter: a comma-separated list of source ids to include (omit for all). `GET /api/sessions/facets` correspondingly returns a `sources: string[]` array (alongside `cwds`) listing the distinct `sessions.source` values so the UI can build the filter dropdown.
```bash
curl "http://localhost:4820/api/sessions?sources=local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11"
```
---
### Claude Config Explorer
The `/api/cc-config/*` namespace powers the Claude Config Explorer, now the **Claude Code** tab of Settings (`/settings?tool=claude-code`). All read endpoints are pure file reads under `CLAUDE_HOME` and the project's `.claude/` dir. File mutations cover the low-risk text-file artifacts (skills, subagents, slash commands, output styles, memory), `keybindings.json`, and `settings.json` layers **via a per-key merge only** — each always creates a timestamped backup before writing. Plugin and marketplace management is delegated to the `claude plugin …` CLI. MCP server config stays read-only, because it is written concurrently by the running Claude Code CLI.
```http
GET /api/cc-config/overview
GET /api/cc-config/skills?scope=user|project|all
GET /api/cc-config/agents
GET /api/cc-config/commands
GET /api/cc-config/output-styles
GET /api/cc-config/plugins
GET /api/cc-config/marketplaces
GET /api/cc-config/mcp
GET /api/cc-config/hooks
GET /api/cc-config/hook-scripts
GET /api/cc-config/keybindings
PUT /api/cc-config/keybindings Body: { groups: [{ context, bindings: [{ key, action }] }] }
GET /api/cc-config/statusline
GET /api/cc-config/settings
GET /api/cc-config/memory
GET /api/cc-config/file?path=<absolute-path>
GET /api/cc-config/backups[?scope=&type=]
PUT /api/cc-config/file Body: { scope, type, name?, content }
DELETE /api/cc-config/file Body: { scope, type, name? }
PATCH /api/cc-config/settings Body: { scope, set?: { "dotted.key": value }, unset?: ["dotted.key"] }
GET /api/cc-config/plugins/available
POST /api/cc-config/plugins/install Body: { spec, scope? }
POST /api/cc-config/plugins/action Body: { key, action: enable|disable|update|uninstall }
POST /api/cc-config/marketplaces/add Body: { source, scope? }
POST /api/cc-config/marketplaces/action Body: { name, action: update|remove }
```
`scope` is `"user"`, `"project"`, or `"auto-memory"`. `type` is one of `skills`, `agents`, `commands`, `output-styles`, `memory`, `auto-memory`. `name` is required for everything except `memory` (which is `CLAUDE.md` itself). On `PUT`, `name` is validated against `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` (for `auto-memory` it must instead be a flat `*.md` filename). Settings are returned with secret-like keys (matching `/token|secret|password|api[_-]?key|auth/i`) replaced by `"<redacted>"`.
`GET /api/cc-config/memory` also surfaces the per-project file-based memory store — every `*.md` under `~/.claude/projects/<slug>/memory/` (the common pattern of a `MEMORY.md` index plus one file per remembered fact). Those items have `scope: "auto-memory"` and carry `project` (the `projects/<slug>` dir name), `name` (filename), `isIndex` (true for `MEMORY.md` / `INDEX-*.md`, which sort first), and parsed `frontmatter`. They are **editable**: `PUT`/`DELETE /api/cc-config/file` accept `{ scope: "auto-memory", type: "auto-memory", project, name, content? }` and create a timestamped backup under `<memory-dir>/.cc-config-backups/auto-memory/` before mutating (an invalid `project` slug returns `EBADPROJECT`). `GET /api/cc-config/backups` lists these with `scope: "auto-memory"` and `project` set. Bodies are also readable via `GET /api/cc-config/file` (they live under `CLAUDE_HOME`).
**`PATCH /api/cc-config/settings` (key merge).** `scope` is `"user"`, `"project"`, or `"project-local"` (`settings.local.json`). `set` maps dotted key paths to JSON values; `unset` is a list of dotted key paths to delete. The server re-reads the target file **unredacted**, applies only the named keys, backs it up under `<dir>/.cc-config-backups/settings/`, and writes atomically — so keys the UI never displayed (including secret-valued ones) survive byte-for-byte. Returns `{ path, backupPath, created, changed }`. Hooks are edited through this same route, as `set: { "hooks.PreToolUse": [{ matcher, hooks: [{ type, command, timeout? }] }] }` — i.e. `settings.json`'s own nested shape, not the flattened shape `GET /hooks` returns. Errors: `EBADSCOPE` (unknown scope), `EBADNAME` (a key segment outside `[A-Za-z0-9_$][A-Za-z0-9._$-]*`, or `__proto__`/`prototype`/`constructor`), `EREDACTED` (400 — the payload contains the `<redacted>` placeholder, which would destroy a real secret), `EBADJSON` (409 — the file on disk is not valid JSON; it is left untouched rather than clobbered), `ETOOLARGE`. The project layers always resolve against the **server's** cwd; `?cwd=` is honoured on reads only, never on this write.
**Plugins and marketplaces.** These five endpoints shell out to the `claude plugin …` CLI with an explicit argv array (never a shell string), pinned to the dashboard's `CLAUDE_HOME` via `CLAUDE_CONFIG_DIR`; mutating calls are serialized in-process. `source` must be `owner/repo`, an `http(s)://` URL, or an absolute path; `spec`/`key` must be `<name>` or `<name>@<marketplace>`; `scope` is `user` (default), `project` or `local`. Validation failures return 400 `EBADARG`; a missing `claude` binary returns 503 `ECLINOTFOUND`; a CLI timeout returns 504 `ETIMEOUT`; a non-zero CLI exit returns **422 `ECLIEXIT`** with the CLI's message plus `stdout`/`stderr`. Successful runs return `{ ok: true, stdout, stderr }` and broadcast `cc_config_changed`. `GET /plugins/available` proxies `claude plugin list --available --json` as `{ installed, available }`.
`PUT /api/cc-config/keybindings` edits `~/.claude/keybindings.json` from a structured list of context groups (`{ groups: [{ context, bindings: [{ key, action }] }] }`). The server backs the file up first (under `<CLAUDE_HOME>/cc-config-backups/keybindings/`), preserves any top-level metadata (`$schema`/`$docs`), and replaces only the `bindings` array; duplicate contexts or duplicate keys within a context return `EBADCONTENT`. `keybindings.json` is replaced wholesale here because the live CLI does not rewrite it mid-session; `settings.json`, which it does, is only ever key-merged (see `PATCH /settings` above).
Backup paths look like `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` — outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. The Backups modal in the UI auto-builds `mv` restore commands.
### Run Claude via tmux+PTY
The `/api/run/*` namespace starts and manages `claude` subprocesses in tmux-backed pseudoterminals, one session per lane. The Workspace page renders an interactive xterm.js terminal attached to the session over `/ws-pty/:runId` (binary PTY frames, not JSON). Every route enforces a same-origin / loopback-Origin guard; browser requests must come from `localhost`, `127.0.0.1`, `::1`, or `0.0.0.0`. CLI / curl requests with no `Origin` header pass through. When `DASHBOARD_TOKEN` is set, a valid token is also required here (like the rest of `/api/*` — see [Authentication](#authentication)). **`tmux` must be installed on the dashboard server** (same operational requirement as better-sqlite3's native module).
```http
GET /api/run List all live runs (computed fresh from tmux state)
GET /api/run/tmux { available: bool } — whether tmux is on PATH
GET /api/run/binary { found, path } for the `claude` binary
GET /api/run/cwds Suggested cwds (dashboard, home, recent)
GET /api/run/history?limit=&laneId= Persisted run history; laneId narrows to one lane's runs
GET /api/run/files?cwd=&q= Fuzzy file search inside cwd for the @-file autocomplete
(skips node_modules, .git, dist, build, .next, .cache, coverage, vendor)
POST /api/run Start/attach run — Body: { laneId, initialPrompt?, ... }
GET /api/run/:id Run handle (returns live run state)
DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
```
**`POST /api/run` (start/attach):** Requires `laneId` (the lane this run belongs to). Creates or attaches an existing tmux session named `ccam-lane-<id>` in the lane's working directory. If that session already exists and its pane is idling at a shell prompt, the `claude` command line (including `--resume` and any initial prompt) is typed into that pane instead of being dropped; if the pane is running a program, the request adopts the session unchanged. Optionally accepts `initialPrompt` to immediately type/send into the session (if empty or omitted, the session is created/attached with no initial input). Returns `{ id, laneId, status, cwd, model, permissionMode, effort, resumeSessionId, sessionId, startedAt, promptPreview }` where `id` is the tmux session name. The dashboard self-heals a lane's `run_id`/`status` on every read if the tmux session has been killed externally.
`permissionMode` (`acceptEdits` default, or `default` / `plan` / `bypassPermissions`) is the mode the pane starts in. The pane's argv also carries `--allow-dangerously-skip-permissions`, which only makes **bypass permissions** selectable in the pane's `shift+tab` cycle — it does not enable it. See [docs/LANES.md § start](LANES.md).
**PTY streaming:** Frames from the tmux pane are streamed to the client over `/ws-pty/:runId` as binary WebSocket frames (not JSON). The Workspace page's TerminalView component feeds these frames to xterm.js for live rendering. Simultaneously, `ccam lanes shell` can attach the same session via a real local terminal, staying in sync with the browser view.
Every history row carries `lane_id`: the lane whose run it belongs to. `GET /api/run/history?laneId=<n>` returns only that lane's runs. Spawned `claude` processes fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the Dashboard's Analytics tab, and the Workflows page automatically.
---
## WebSocket API
### Connection
```javascript
const ws = new WebSocket('ws://localhost:4820/ws');
ws.onopen = () => {
console.log('Connected to Agent Dashboard');
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
console.log('Received:', message);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
console.log('Disconnected');
};
```
When `DASHBOARD_TOKEN` is configured, pass the token as `?token=<token>` on the `/ws` upgrade (an `x-dashboard-token` header also works):
```javascript
const ws = new WebSocket('ws://localhost:4820/ws?token=YOUR_DASHBOARD_TOKEN');
```
### WebSocket Lifecycle
```mermaid
stateDiagram-v2
[*] --> Connecting: new WebSocket()
Connecting --> Connected: onopen
Connecting --> Disconnected: onerror
Connected --> Connected: onmessage
Connected --> Disconnected: onclose
Connected --> Disconnected: onerror
Disconnected --> Connecting: Reconnect
Disconnected --> [*]
note right of Connected
Heartbeat: ping every 30s
Broadcast: Real-time events
end note
```
### Event Types
Server broadcasts JSON messages to all connected clients:
#### session.created
Sent when a new session is created.
```json
{
"type": "session.created",
"data": {
"id": 1,
"session_id": "sess_abc123",
"model": "claude-sonnet-4",
"status": "active",
"total_cost": 0,
"created_at": "2024-03-18T12:00:00Z",
"updated_at": "2024-03-18T12:00:00Z"
}
}
```
#### session.updated
Sent when session data changes (status, cost, etc.).
```json
{
"type": "session.updated",
"data": {
"id": 1,
"session_id": "sess_abc123",
"model": "claude-sonnet-4",
"status": "completed",
"total_cost": 1.23,
"created_at": "2024-03-18T12:00:00Z",
"updated_at": "2024-03-18T14:30:00Z"
}
}
```
#### agent.created
Sent when a new agent starts.
```json
{
"type": "agent.created",
"data": {
"id": 1,
"agent_id": "agent_xyz789",
"session_id": "sess_abc123",
"agent_type": "explore",
"status": "running",
"current_tool": null,
"input_tokens": 0,
"output_tokens": 0,
"cost": 0,
"created_at": "2024-03-18T12:00:00Z",
"updated_at": "2024-03-18T12:00:00Z"
}
}
```
#### agent.updated
Sent when agent data changes (tokens, status, current_tool).
```json
{
"type": "agent.updated",
"data": {
"id": 1,
"agent_id": "agent_xyz789",
"session_id": "sess_abc123",
"agent_type": "explore",
"status": "completed",
"current_tool": null,
"input_tokens": 1500,
"output_tokens": 800,
"cost": 0.45,
"created_at": "2024-03-18T12:00:00Z",
"updated_at": "2024-03-18T12:05:00Z"
}
}
```
#### tool.executed
Sent when a tool execution completes.
```json
{
"type": "tool.executed",
"data": {
"id": 1,
"agent_id": "agent_xyz789",
"tool_name": "bash",
"duration_ms": 1234,
"success": 1,
"error_message": null,
"created_at": "2024-03-18T12:01:00Z"
}
}
```
#### notification.received
Sent when a notification is created.
```json
{
"type": "notification.received",
"data": {
"id": 1,
"session_id": "sess_abc123",
"notification_type": "backgroundTaskComplete",
"message": "Explore agent completed",
"created_at": "2024-03-18T12:05:00Z"
}
}
```
#### /ws-pty/:runId — PTY frames
A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on lane load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `ccam lanes shell`, other tools), all receiving the same frames live-synced. Resize events: the client sends a `TIOCSWINSZ` ioctl down the pane's pty when the browser terminal is resized, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
#### cc_config_changed
Broadcast whenever Claude Code configuration changes — either by dashboard mutations on `PUT/DELETE /api/cc-config/file` (`source: "dashboard"`) or by `lib/cc-watcher.js` picking up external `fs.watch` events on `~/.claude/` and `~/.claude.json` (`source: "fs"`, debounced at 500 ms). The Config Explorer page subscribes and refetches automatically.
```json
{ "type": "cc_config_changed", "data": { "source": "dashboard", "action": "write", "scope": "user", "type": "skill", "name": "my-skill" } }
{ "type": "cc_config_changed", "data": { "source": "fs", "paths": ["/Users/foo/.claude/settings.json"] } }
```
#### remote_data.updated
Broadcast once per successful remote sync (background poller, manual **Sync now**, or immediate pull after add/re-enable). Clients use this — and the per-session `session_created` / `session_updated` frames emitted in the same pass — to refetch sessions, costs, and analytics without polling.
```json
{
"type": "remote_data.updated",
"data": {
"sourceId": "src_a1b2c3",
"source": "src_a1b2c3",
"label": "dev-box",
"counters": { "imported": 1, "skipped": 0, "sessions_tagged": 3 },
"last_sync_at": "2026-07-26T21:15:00.000Z"
}
}
```
#### remote_source.status
Broadcast when a remote data source changes sync state (during/after `POST /api/remote-sources/:id/sync`) or is deleted. `status` is one of `idle`, `syncing`, `ok`, `error`, or `deleted`. `error` and `last_sync_at` are optional and present when relevant. See [Remote Data Sources](#remote-data-sources).
```json
{ "type": "remote_source.status", "data": { "id": "4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11", "status": "syncing" } }
{ "type": "remote_source.status", "data": { "id": "4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11", "status": "ok", "last_sync_at": "2026-07-22T18:41:55.117Z" } }
{ "type": "remote_source.status", "data": { "id": "4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11", "status": "error", "error": "ssh exited with code 255" } }
{ "type": "remote_source.status", "data": { "id": "4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11", "status": "deleted" } }
```
### Event Flow
```mermaid
sequenceDiagram
participant Hook as Hook Handler
participant Server as Express Server
participant DB as SQLite
participant WS as WebSocket Server
participant Client1 as Client 1
participant Client2 as Client 2
Hook->>Server: POST /hooks/post-tool-use
Server->>DB: Create tool_execution
DB-->>Server: Inserted row
Server->>WS: broadcast({ type: 'tool.executed', data })
par Broadcast to all clients
WS->>Client1: { type: 'tool.executed', ... }
WS->>Client2: { type: 'tool.executed', ... }
end
Server-->>Hook: 200 OK
```
---
## Error Handling
### Error Response Format
All error responses follow this structure:
```json
{
"error": "Human-readable error message",
"code": "ERROR_CODE",
"details": {
"field": "Additional context"
}
}
```
### HTTP Status Codes
| Code | Meaning | Example |
|------|---------|---------|
| 200 | Success | Resource retrieved |
| 201 | Created | Resource created |
| 400 | Bad Request | Invalid JSON, missing fields |
| 404 | Not Found | Session/agent not found |
| 409 | Conflict | Duplicate pattern |
| 500 | Server Error | Database error |
### Error Examples
**400 Bad Request:**
```json
{
"error": "Missing required field: pattern",
"code": "VALIDATION_ERROR",
"details": {
"field": "pattern",
"message": "Pattern is required"
}
}
```
**404 Not Found:**
```json
{
"error": "Session not found",
"code": "NOT_FOUND",
"details": {
"session_id": "sess_invalid"
}
}
```
**409 Conflict:**
```json
{
"error": "Pricing rule already exists",
"code": "DUPLICATE_PATTERN",
"details": {
"pattern": "claude-sonnet-4"
}
}
```
---
## Rate Limiting
Currently, no rate limiting is enforced. For production deployments, implement rate limiting:
```javascript
// Using express-rate-limit
import rateLimit from 'express-rate-limit';
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
message: 'Too many requests, please try again later.'
});
app.use('/api/', limiter);
```
---
## Pagination
For endpoints returning lists, use `limit` and `offset`:
```http
GET /api/sessions?limit=20&offset=40
```
**Pagination Pattern:**
```mermaid
graph LR
Page1[Page 1<br/>offset=0<br/>limit=20] --> Page2[Page 2<br/>offset=20<br/>limit=20]
Page2 --> Page3[Page 3<br/>offset=40<br/>limit=20]
Page3 --> PageN[Page N<br/>offset=N*20<br/>limit=20]
style Page1 fill:#3B82F6
```
**Response includes pagination metadata:**
```json
{
"sessions": [...],
"total": 156,
"limit": 20,
"offset": 40,
"has_more": true
}
```
---
## Examples
### Full Session Workflow
```javascript
// 1. List sessions
const sessions = await fetch('http://localhost:4820/api/sessions');
const { sessions: sessionList } = await sessions.json();
// 2. Get specific session
const sessionId = sessionList[0].session_id;
const session = await fetch(`http://localhost:4820/api/sessions/${sessionId}`);
const sessionData = await session.json();
// 3. Get session agents
const agents = await fetch(`http://localhost:4820/api/sessions/${sessionId}/agents`);
const { agents: agentList } = await agents.json();
// 4. Get agent tools
const agentId = agentList[0].agent_id;
const tools = await fetch(`http://localhost:4820/api/agents/${agentId}/tools`);
const { tools: toolList } = await tools.json();
console.log('Session:', sessionData);
console.log('Agents:', agentList);
console.log('Tools:', toolList);
```
### Real-time Monitoring
```javascript
// Connect to WebSocket
const ws = new WebSocket('ws://localhost:4820/ws');
ws.onopen = () => {
console.log('Connected to real-time stream');
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
switch (message.type) {
case 'session.created':
console.log('New session:', message.data.session_id);
break;
case 'agent.updated':
console.log('Agent updated:', message.data.agent_id);
console.log('Cost:', message.data.cost);
break;
case 'tool.executed':
console.log('Tool executed:', message.data.tool_name);
console.log('Duration:', message.data.duration_ms, 'ms');
break;
}
};
```
### Creating Pricing Rules
```javascript
// Create custom rule
const response = await fetch('http://localhost:4820/api/pricing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pattern: 'my-custom-model',
input_cost_per_1m: 5.0,
output_cost_per_1m: 20.0
})
});
const { rule } = await response.json();
console.log('Created rule:', rule);
// List all rules
const rules = await fetch('http://localhost:4820/api/pricing');
const { rules: ruleList } = await rules.json();
console.log('All rules:', ruleList);
// Delete rule
await fetch('http://localhost:4820/api/pricing/my-custom-model', {
method: 'DELETE'
});
```
---
## Summary
The Agent Dashboard API provides:
- ✅ **RESTful endpoints** for querying sessions, agents, tools, pricing
- ✅ **WebSocket streaming** for real-time updates
- ✅ **Type-safe responses** with consistent JSON structure
- ✅ **Error handling** with descriptive error codes
- ✅ **Pagination** for large datasets
- ✅ **Pricing management** with custom rule support
For interactive API exploration with live request/response examples, see the built-in Swagger UI at `/api/docs` and ReDoc at `/api/redoc`. For MCP integration, see [MCP.md](./MCP.md).