docs/API.md's Locks section described a timeoutMs request param, a 408-timeout response, and field names (acquiredAt/acquiredMs) that don't exist anywhere in the actual routes.js — the server is single-shot and never blocks; timing out is a CLI-only concept. Also fixes the section being spliced into the middle of the pre-existing Sessions heading and its content. docs/LANES.md said the owner file stores an epoch in milliseconds (it's seconds), that the CLI polls every ~1s (it's ~2s), that the lane card shows a "waiting" state (no such server-side concept exists, only who currently holds), and included a fabricated "manually transfer a lock's holder identity" procedure that also contradicted the never-touch-the-lock-directory etiquette rule stated right above it.
58 KiB
API Reference
Complete REST API and WebSocket documentation for Agent Dashboard.
Table of Contents
- Overview
- Authentication
- Base URL
- REST API
- WebSocket API
- Error Handling
- Rate Limiting
- Pagination
- Examples
Overview
The Agent Dashboard API provides programmatic access to Claude Code session monitoring data.
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 withDASHBOARD_HOST(e.g.DASHBOARD_HOST=0.0.0.0for 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>headerx-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.
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, ornullif no signal has been seen. Independent of the agent's own declaredstage.detected_signal— the capped (≤120 char) tool-event string that produceddetected_stage;nullwhendetected_stageisnull.detected(boolean, on each entry ofpipeline_nodes) —truefor the inferred node and any node before it that carries no declared record. Decorates that node's existingstate; never upgrades it todone— an inferred stage is never evidence.
Find or adopt the lane owning a directory
POST /api/lanes/ensure
{ "cwd": "/absolute/path/to/work", "title": "App package" }
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 or missing cwd returns 400 EBADCWD. 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
PATCH /api/lanes/:id
Partially updates a lane. This route is same-origin guarded because its patch
can set run_id.
Provision a managed worktree
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.
{
"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
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
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.
{
"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:
{
"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
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.
{
"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
}
databaseisnullwhen the profile declares noDB_PREFIX;redisIndexisnullwhenREDISis not1. Both are names/indices only — never a connection string, so this endpoint never leaks the~/.ccam/secrets.envpassword even though it derivesDATABASE_URLinternally to run hooks.- A lane whose repository declares no
.ccam/profilereturns{"available": false, "searched": [...]}with HTTP 200 — the same contract asGET /: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": {}}. expectedisPORT_BASE_<name> + slot; whenportdiffers, the allocator stepped aside from a number already in use andsteppedAsideistrue.- Not folded into
GET /api/laneson 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
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:
{ "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
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
GET /api/lanes/:id/logs/:svc?tail=65536
{ "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}.
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
GET /api/locks
Every currently-held lock:
{
"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
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/emptyholder.
Release a lock
POST /api/locks/:name/release
{ "holder": "lane3" }
- 200
{ "ok": true }— released. - 409
{ "error": { "code": "ENOTHOLDER", "message": "...", "currentHolder": "lane1" } }—holderdoesn'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
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 |
Example Request:
curl http://localhost:4820/api/sessions?limit=10&status=active
Example Response:
{
"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:
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
GET /api/sessions/:id
Returns single session details.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Session ID (e.g., sess_abc123) |
Example Request:
curl http://localhost:4820/api/sessions/sess_abc123
Example Response:
{
"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
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:
curl http://localhost:4820/api/sessions/sess_abc123/stats
Example Response:
{
"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
GET /api/sessions/:id/agents
Returns all agents for a session.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Session ID |
Example Request:
curl http://localhost:4820/api/sessions/sess_abc123/agents
Example Response:
{
"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/agentsand/api/sessions/:id/agentsattach acost(USD) to each agent: the agent's own cost, computed server-side from the per-agent token buckets stored inagents.metadata.tokensand priced at the current pricing rules (at the agent's start date, so promo/standard cutovers apply — see Pricing). It is0for 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
statusvs Waiting — agents are persisted with one ofidle | connected | working | completed | error. The yellow Waiting badge surfaced in the dashboard is a UI overlay derived fromawaiting_input_sincebeing non-null on a non-terminal agent (typicallyidleafter aStop, orconnectedright afterSessionStart). Filter?status=idleon/api/agentsand inspectawaiting_input_sinceto enumerate currently-waiting main agents.
Agents
Get Agent
GET /api/agents/:id
Returns single agent details.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Agent ID (e.g., agent_xyz789) |
Example Request:
curl http://localhost:4820/api/agents/agent_xyz789
Example Response:
{
"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
GET /api/agents/:id/tools
Returns tool executions for an agent.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Agent ID |
Example Request:
curl http://localhost:4820/api/agents/agent_xyz789/tools
Example Response:
{
"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:
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
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:
curl http://localhost:4820/api/tools?limit=50&tool_name=bash
Example Response:
{
"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 (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):
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>"
A ready-to-run Prometheus + Grafana stack (four auto-provisioned dashboards; default home CCAM — Overview) lives in monitoring/. npm path (no Docker): npm run monitoring:install then npm run monitoring:up (binaries are pulled via the monitoring package's postinstall — there is no official grafana/prometheus server package on npm). Docker path: npm run monitoring:docker:up or npm run docker:full:up (set DASHBOARD_ALLOWED_HOSTS=host.docker.internal on the dashboard when Prometheus runs in a container). Pre-built Prometheus console: http://localhost:9090/consoles/index.html.
Pricing
List Pricing Rules
GET /api/pricing
Returns all pricing rules (default + custom).
Example Request:
curl http://localhost:4820/api/pricing
Example Response:
{
"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:
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
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:
{
"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:
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:
{
"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
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:
# Pattern must be URL-encoded
curl -X DELETE http://localhost:4820/api/pricing/gpt-5.1-codex
Example Response:
{
"deleted": true
}
Error Responses:
| Code | Description |
|---|---|
| 404 | Pattern not found |
| 403 | Cannot delete default rule |
| 500 | Database error |
Notifications
Get Session Notifications
GET /api/sessions/:id/notifications
Returns notifications for a session.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Session ID |
Example Request:
curl http://localhost:4820/api/sessions/sess_abc123/notifications
Example Response:
{
"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:
{
"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
GET /api/remote-sources
Returns all configured remote sources. Response: { "sources": RemoteSource[] }.
Create Remote Source
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
~/.claudeinclude 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
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
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
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
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 frames.
Example Response:
{
"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
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:
{ "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.
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 page. All read endpoints are pure file reads under CLAUDE_HOME and the project's .claude/ dir; mutations are limited to low-risk text-file artifacts (skills, subagents, slash commands, output styles, memory) and always create a timestamped backup before writing. Plugins, MCP servers, hooks-in-settings, and live settings.json files stay read-only because they are written concurrently by the running Claude Code CLI.
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? }
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).
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. Unlike settings.json (which the live CLI rewrites mid-session and is therefore read-only here), keybindings.json is safe to edit from the dashboard.
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
The /api/run/* namespace spawns and supervises claude subprocesses from the dashboard. 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).
GET /api/run List all handles + concurrency state
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 Spawn — Body: { prompt, mode, cwd?, model?, permissionMode?, resumeSessionId?, effort? }
POST /api/run/:id/message Send follow-up turn — Body: { text }
GET /api/run/:id[?envelopes=1] Handle state; ?envelopes=1 includes the in-memory envelope log
DELETE /api/run/:id Stop (SIGTERM → SIGKILL after 5 s)
mode is "headless" (single-shot, stdin closed after spawn, prompt in argv via -p) or "conversation" (multi-turn, stdin stays open, prompt and follow-ups piped as stream-json envelopes). resumeSessionId requires conversation mode and adds --resume <id> so the run continues an existing Claude Code session — the cwd is locked to the original session's cwd. When resumeSessionId is set, prompt may be empty — the spawner skips the initial stdin write and claude --resume idles on the resumed conversation until the user posts a follow-up via POST /api/run/:id/message. Headless mode and fresh conversations still require a non-empty prompt (EBADPROMPT otherwise). effort ("low" / "medium" / "high") maps to --effort and tunes the model's thinking budget. The spawner always passes --output-format stream-json --verbose --include-partial-messages so output streams over the existing dashboard WebSocket as run_stream (parsed envelopes, including stream_event deltas for character-by-character rendering), run_status (status transitions), and run_input_ack (stdin write confirmed). Concurrency is effectively uncapped (default ceiling 10000, override with RUN_MAX_CONCURRENT) — the terminal TUI has no cap and neither does the dashboard; the ceiling exists only to prevent fork-bomb footguns from a buggy client.
Every history row carries lane_id: the lane the run was started through
(POST /api/lanes/:id/start), or null for a run spawned straight from
POST /api/run. GET /api/run/history?laneId=<n> returns only that lane's
runs, which is what the Workspace page's per-lane history lists.
Spawned claude processes fire the dashboard's hooks like any other CLI session, so they show up in /api/sessions, the analytics, the Kanban board, and the Workflows page automatically — the Run page itself just owns the live streaming UX.
WebSocket API
Connection
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):
const ws = new WebSocket('ws://localhost:4820/ws?token=YOUR_DASHBOARD_TOKEN');
WebSocket Lifecycle
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.
{
"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.).
{
"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.
{
"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).
{
"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.
{
"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.
{
"type": "notification.received",
"data": {
"id": 1,
"session_id": "sess_abc123",
"notification_type": "backgroundTaskComplete",
"message": "Explore agent completed",
"created_at": "2024-03-18T12:05:00Z"
}
}
run_stream / run_status / run_input_ack
Broadcast by routes/run.js and lib/run-spawner.js for /run page subprocesses. run_stream.data.envelope is a parsed stream-json envelope; the spawner runs claude with --include-partial-messages so this includes stream_event deltas (message_start, content_block_delta text/thinking deltas, message_stop, etc.) for character-level streaming.
{ "type": "run_stream", "data": { "id": "<run-id>", "envelope": { "type": "stream_event", "event": { "type": "content_block_delta", "index": 0, "delta": { "type": "text_delta", "text": "Hello" } } } } }
{ "type": "run_status", "data": { "id": "<run-id>", "status": "running", "at": 1700000000000 } }
{ "type": "run_input_ack", "data": { "id": "<run-id>", "messageId": "<uuid>", "at": 1700000000000 } }
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.
{ "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.
{
"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.
{ "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
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:
{
"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:
{
"error": "Missing required field: pattern",
"code": "VALIDATION_ERROR",
"details": {
"field": "pattern",
"message": "Pattern is required"
}
}
404 Not Found:
{
"error": "Session not found",
"code": "NOT_FOUND",
"details": {
"session_id": "sess_invalid"
}
}
409 Conflict:
{
"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:
// 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:
GET /api/sessions?limit=20&offset=40
Pagination Pattern:
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:
{
"sessions": [...],
"total": 156,
"limit": 20,
"offset": 40,
"has_more": true
}
Examples
Full Session Workflow
// 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
// 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
// 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.