Complete REST API and WebSocket documentation for Agent Dashboard.
- Overview
- Authentication
- Base URL
- REST API
- WebSocket API
- Error Handling
- Rate Limiting
- Pagination
- Examples
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
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.
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
http://localhost:4820
For production, use HTTPS:
https://dashboard.example.com
GET /api/sessionsReturns 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=activeExample 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 /api/sessions/:idReturns 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_abc123Example 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 /api/sessions/:id/statsReturns 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/statsExample 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 /api/sessions/:id/agentsReturns all agents for a session.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Session ID |
Example Request:
curl http://localhost:4820/api/sessions/sess_abc123/agentsExample 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.
GET /api/agents/:idReturns 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_xyz789Example 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 /api/agents/:id/toolsReturns tool executions for an agent.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Agent ID |
Example Request:
curl http://localhost:4820/api/agents/agent_xyz789/toolsExample 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
GET /api/toolsReturns 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=bashExample 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
}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.
GET /api/pricingReturns all pricing rules (default + custom).
Example Request:
curl http://localhost:4820/api/pricingExample 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
PUT /api/pricingUpsert 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 /api/pricing/:patternDelete 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-codexExample Response:
{
"deleted": true
}Error Responses:
| Code | Description |
|---|---|
| 404 | Pattern not found |
| 403 | Cannot delete default rule |
| 500 | Database error |
GET /api/sessions/:id/notificationsReturns notifications for a session.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Session ID |
Example Request:
curl http://localhost:4820/api/sessions/sess_abc123/notificationsExample Response:
{
"notifications": [
{
"id": 1,
"session_id": "sess_abc123",
"notification_type": "backgroundTaskComplete",
"message": "Explore agent completed",
"created_at": "2024-03-18T12:05:00Z"
}
]
}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.
GET /api/remote-sourcesReturns all configured remote sources. Response: { "sources": RemoteSource[] }.
POST /api/remote-sourcesRequest 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) |
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 |
PATCH /api/remote-sources/:idPartial 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 /api/remote-sources/:idQuery 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.
POST /api/remote-sources/:id/testRuns 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.
POST /api/remote-sources/:id/syncPulls 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.
POST /api/remote-sources/sync-allPulls 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" }] }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"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.
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/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.
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.
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');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
Server broadcasts JSON messages to all connected clients:
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"
}
}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"
}
}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"
}
}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"
}
}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"
}
}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"
}
}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 } }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"] } }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" } }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
All error responses follow this structure:
{
"error": "Human-readable error message",
"code": "ERROR_CODE",
"details": {
"field": "Additional context"
}
}| 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 |
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"
}
}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);For endpoints returning lists, use limit and offset:
GET /api/sessions?limit=20&offset=40Pagination 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
}// 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);// 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;
}
};// 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'
});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.