Files
AgentHub/docs/API.md
T
nntrivi2001 acff059e92 fix(codex-config): close whole-branch review defects in the Codex panel
Plugins/marketplaces GET routes were silently empty because codex-cli.js
never unwrapped the CLI's {installed,available}/{marketplaces} objects
into arrays; backup writes bypassed the symlink-escape guard entirely;
readFileSafe was stricter than every other Codex reader, showing a
symlinked skill/rule in the list and then refusing to open it;
marketplaceAdd only accepted http(s) URLs, rejecting owner/repo sources
the CLI itself takes. Also: docs drift, a dead i18n key, and a scope-cast
hazard in the shared Editor/ConfirmDelete modals.

Fixes, one per finding: unwrap installed/marketplaces in codex-cli.js so
every list route keeps returning a bare list; guard the backup path in
codex-mutate.js with the same assertNotSymlinkEscape the write path
already used (Claude's cc-mutate.js has no such guard on its primary
write path either, so left untouched there); relax readFileSafe's
containment to lexical-only, matching the other four Codex readers,
while leaving the write side's realpath check untouched; reuse
cc-plugin-cli's assertSource for marketplaceAdd instead of a third
validator (ssh remains out of scope, no credential story for it yet).

Server: 1557/1557 (was 1548). Client: tsc -b clean, 469/469 tests.
2026-09-09 01:17:06 +07:00

84 KiB
Raw Blame History

API Reference

Complete REST API and WebSocket documentation for Agent Dashboard.


Table of Contents


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 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 agenthub 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, 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

POST /api/lanes/ensure
{ "cwd": "/absolute/path/to/work", "title": "App package", "pipeline": "ship-feature", "tool": "codex" }

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.

tool ("claude" or "codex", default "claude") is the CLI this lane's runs will spawn. Immutable once the lane is created; an attempt to update it on an existing lane returns 400 EBADTOOL. The tool choice appears as an immutable badge on the lane card (Codex lanes only).

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

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 (agenthub 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 agenthub stage.

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

tool ("claude" or "codex", default "claude") is the CLI this lane's runs will spawn. Immutable once the lane is created; an attempt to update it on an existing lane returns 400 EBADTOOL.

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.

Start a lane's run

POST /api/lanes/:id/start

Starts a new run in the lane's working directory, or attaches an existing idle tmux session. Returns the updated lane. The request body carries:

{
  "prompt": "Write a test for the user endpoint",
  "model": "claude-opus-5",
  "permissionMode": "plan",
  "effort": "medium",
  "resumeSessionId": "session-xyz",
  "sandbox": "read-only",
  "approval": "on-request"
}

All fields are optional. The tool (CLI invoked) comes from the lane itself, never the request — a client cannot override a lane's tool. If the lane is a Codex lane, three fields control Codex-specific behavior:

  • sandbox — one of read-only, workspace-write, or danger-full-access, or omitted/empty for no sandbox. Passed to codex -s <value>.
  • approval — one of on-request or never, or omitted/empty for no approval prompt. Passed to codex -a <value>.
  • resumeSessionId or resumeLast: true — resume an earlier run instead of starting new. For Codex lanes specifically, resumeLast: true uses codex resume --last; resumeSessionId uses codex resume <sessionId>. For Claude lanes, resumeSessionId re-attaches a Claude session by id.

Error 409 ERUNLIVE is returned if the lane already has a live run; stop it first with the stop action.

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/.agenthub/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/agenthub-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 ~/.agenthub/secrets.env password even though it derives DATABASE_URL internally to run hooks.
  • A lane whose repository declares no .agenthub/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

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

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.

GET /api/lanes/:id/features

Every feature this lane has ever activated, archived or live, most recently touched first:

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

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.

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

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.

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:

{
  "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
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
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/.

{ "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
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 agenthub lanes proof-link. Idempotent.

Response:

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

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.
{
  "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

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.

{
  "skills": [
    { "name": "agenthub:ship-feature", "plugin": "agenthub", "count": 12, "sessions": 5 },
    { "name": "daily-report", "plugin": null, "count": 3, "sessions": 3 }
  ],
  "plugins": [
    { "name": "agenthub", "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 agenthub 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; agenthub 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

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

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

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
agenthub_up gauge — 1 when the API served the scrape
agenthub_build_info gauge version Always 1; dashboard version rides on the label
agenthub_process_uptime_seconds gauge — Server process uptime
agenthub_process_resident_memory_bytes gauge — Server process RSS
agenthub_sessions gauge status (active/completed/error/abandoned) Sessions by status
agenthub_agents gauge status (working/waiting/completed/error) Agents by status
agenthub_events_total counter — Total events recorded
agenthub_websocket_clients gauge — Connected realtime clients
agenthub_remote_sources gauge enabled (true/false) Configured Remote Data Sources
agenthub_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: agenthub
    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

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 ~/.claude include Cursor agent usage on that machine too — Cursor happens to use the same paths as Claude Code. AgentHub 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, 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.

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=]
GET /api/cc-config/profiles
POST /api/cc-config/profiles              Body: { name? }
PATCH /api/cc-config/profiles             Body: { name, label }
POST /api/cc-config/profiles/activate     Body: { name }
DELETE /api/cc-config/profiles            Body: { name }
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/plugins returns each installed plugin's contributes block: the skills / agents / commands / outputStyles / hooks counts, plus contributes.items listing the first four surfaces by name ([{ name, description, file }]) so the UI can show which artifacts a plugin ships, not only how many. name is the file's frontmatter name when it declares one — the string Claude Code actually addresses it by — otherwise the filename. Each surface covers both the conventional directory (skills/, agents/, commands/, output-styles/) and any path plugin.json declares under that key: a plugin that lists its commands explicitly and keeps nothing in commands/ (this repo's own agenthub does) previously reported zero. A declared path that resolves outside the plugin's install directory is ignored — a third-party manifest must not make the dashboard read arbitrary files. The counts are always the matching list's length. hooks has no items list: a plugin's hooks are matcher/command pairs declared inline in plugin.json, not named artifacts.

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

Auth profiles (/profiles*). A profile is one Anthropic account — one quota — under a name. Switching swaps exactly two identity artifacts and nothing else: claudeAiOauth inside <CLAUDE_HOME>/.credentials.json, and oauthAccount + userID inside the .claude.json config file(s) (<CLAUDE_HOME>.json and <CLAUDE_HOME>/.claude.json, each rewritten only if it already exists, key-merged so projects/numStartups/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account. mcpOAuth also stays put: those are the machine's MCP server tokens, not account identity.

Profiles live one JSON file per profile under <CLAUDE_HOME>/agenthub-profiles/ (dir 0700, files 0600) and hold live OAuth tokens, so no route ever returns token material — GET /profiles answers { items, active } with identity metadata only (name, label, savedAt, active, accountUuid, email, displayName, organization, seatTier, subscriptionType, expiresAt).

A profile has two names, and the split is what lets a profile be called Tài khoản chính. name is the internal identifier every mutation route takes, and it is the filename, so it must match ^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$; callers normally never choose it, because POST /profiles derives it from the account. label is the display name the user edits with PATCH /profiles: free-form 1-60 characters, spaces and diacritics allowed since it is never used as a path, stored trimmed, rejected for control characters (EBADLABEL), and unique across profiles case-insensitively (ELABELTAKEN). A rename rewrites the label in place and does not move name, so it cannot half-fail and leave a profile unreachable. Profiles written before labels existed report label equal to their name.

One account is one profile: POST /profiles refuses with EDUPLICATE when another profile already holds the logged-in account, naming the one that does. Two profiles of a single account would both report active: true, leaving nothing to switch between — and, under a blanket active-guard on delete, neither would ever be removable. Re-posting an account into its OWN profile is the token-refresh path, stays allowed, and preserves that profile's label.

The dashboard cannot log an account in — only claude auth login mints tokens. Adding a profile is therefore always "log in from a terminal, then POST /profiles to snapshot what the CLI wrote". POST /profiles/activate re-snapshots the outgoing account into its own profile first (reported as savedOutgoing), so tokens Claude Code refreshed since the last save are not lost, and returns { ok, name, savedOutgoing, written }. Errors: EBADNAME / EBADLABEL (400), ENOTFOUND (404 — no such profile), EBADJSON (409 — a config file on disk is unparseable; nothing is written), ENOAUTH (409 — nobody is logged in to snapshot), EDUPLICATE (409 — another profile already holds this account), ELABELTAKEN (409 — another profile already uses that label), EUNSAVED (409 — the live account is not saved as a profile, and switching away would discard the only copy of its credentials), EACTIVE (409 — refusing to delete the last saved copy of the live account; a redundant copy is deletable, which is what makes an accidental duplicate cleanable). Mutations broadcast cc_config_changed with type: "profile". A switch only takes effect for new Claude Code sessions, and a session still running will rewrite the config on exit — close them first.

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.

Codex Config Explorer

The /api/codex-config/* namespace powers the Codex tab of Settings (/settings?tool=codex) — the Codex CLI counterpart of the Claude Config Explorer above. Codex has a narrower scope union than Claude Code: "user" or "project" only, no "project-local" and no per-project file-based memory. Reads for skills/agents/rules/memory/config are pure file reads (server/lib/codex-discovery.js); MCP servers, plugins, and marketplaces have no on-disk reader and are read and mutated entirely through the codex CLI (server/lib/codex-cli.js), because the CLI owns config.toml's schema.

GET /api/codex-config/overview
GET /api/codex-config/skills?scope=user|project|all
GET /api/codex-config/agents?scope=user|project|all
GET /api/codex-config/rules?scope=user|project|all
GET /api/codex-config/memory
GET /api/codex-config/config
GET /api/codex-config/file?path=<absolute-path>
PUT /api/codex-config/file      Body: { scope, type, name?, content }
DELETE /api/codex-config/file   Body: { scope, type, name? }
GET /api/codex-config/backups[?scope=&type=]
GET /api/codex-config/mcp
GET /api/codex-config/plugins
GET /api/codex-config/marketplaces
POST /api/codex-config/mcp/add             Body: { name, url, bearerTokenEnvVar? } | { name, command, args?, env? }
POST /api/codex-config/mcp/remove          Body: { name }
POST /api/codex-config/plugins/action      Body: { spec, action: add|remove }
POST /api/codex-config/marketplaces/add    Body: { url }
POST /api/codex-config/marketplaces/action Body: { name, action: remove }

type is one of skills, agents, rules, memory, config. name is required for skills/agents/rules and forbidden for memory/config (each is a single fixed file per scope — AGENTS.md and config.toml respectively). PUT/DELETE always back the previous content up first (server/lib/codex-mutate.js) and always resolve project scope against the dashboard server's own cwd — unlike the read routes, there is no ?cwd= override on a write, for the same reason cc-config.js's PATCH /settings refuses one. Mutation errors: EBADTYPE/EBADSCOPE/EBADNAME/EBADCONTENT/EOUTOFROOT (400), ETOOLARGE (413), ENOTFOUND (404). Every successful write/delete broadcasts codex_config_changed.

GET /mcp, /plugins, /marketplaces proxy codex mcp list --json / codex plugin list --json / codex plugin marketplace list --json; a fresh install with none of a kind returns { mcp: [] } etc. rather than a parse error. Unlike mcp list --json (a bare array), plugin list --json and plugin marketplace list --json return {installed, available} / {marketplaces} objects — server/lib/codex-cli.js unwraps those down to a plain array before the route ever sees them, so every list route's contract stays "the named field is a list"; the client's Array.isArray coercion is only a defensive fallback for CLI output that fails to parse at all. The add/remove routes shell out to codex … with an explicit argv array (never a shell string); a non-zero CLI exit is reported as 422 ECLIEXIT with stdout/stderr preserved, never a 500. POST /mcp/add takes either { name, url, bearerTokenEnvVar? } (HTTP transport) or { name, command, args?, env? } (stdio transport) — never both, mirroring codex mcp add's own mutually-exclusive flags. POST /marketplaces/add's url field is misnamed for history but accepts the same source shapes as codex plugin marketplace add: owner/repo, an http(s) git URL, or an absolute local path (ssh remotes are out of scope). Other CLI errors: EBADARG (400), ECLINOTFOUND (503 — the codex binary isn't on PATH), ETIMEOUT (504).

Because CodexMcpServer/CodexPlugin/CodexMarketplace are opaque JSON blobs from the CLI ([key: string]: unknown) rather than a shape the dashboard controls, the Codex tab renders them as generic key/value cards instead of the richly-typed plugin/MCP cards the Claude Code tab shows.

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). tmux must be installed on the dashboard server (same operational requirement as better-sqlite3's native module).

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?tool=          { found, path } for the `claude` or `codex` 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, cwd?, model?, permissionMode?, effort?, resumeSessionId?, initialPrompt? }
GET    /api/run/:id                   Run handle (returns live run state)
DELETE /api/run/:id                   Kill (SIGTERM → SIGKILL after 5 s)

GET /api/run/binary: Probes for the binary (claude or codex). Optional query parameter ?tool=codex probes for Codex; omitted or ?tool=claude probes for Claude. Returns { found: bool, path: string | null }.

POST /api/run (start/attach): Requires laneId (the lane this run belongs to). Creates or attaches an existing tmux session named agenthub-lane-<id> in the lane's working directory. If that session already exists and its pane is idling at a shell prompt, the appropriate 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.

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, agenthub 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) and tool (the CLI that was invoked: "claude" or "codex"). 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. Codex runs do not emit these hooks; they are recorded only in run history and dashboard_runs.


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

/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, agenthub lanes shell, other tools), all receiving the same frames live-synced. Resize events: the client sends a {"type":"resize","cols","rows"} text frame and the server applies it as a TIOCSWINSZ ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from xterm's own onResize, not from the container's ResizeObserver — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and re-asserts it whenever the terminal regains focus, because window-size latest lets a second attach client (another tab, agenthub lanes shell) resize the tmux window out from under this one. Keeping the two widths equal is not cosmetic: a tmux pane wider than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. 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.

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

codex_config_changed

The Codex counterpart of cc_config_changed — broadcast by routes/codex-config.js after every successful mutation: PUT/DELETE /api/codex-config/file, and the CLI-backed mcp/add, mcp/remove, plugins/action, marketplaces/add, marketplaces/action routes. Always source: "dashboard" — there is no filesystem watcher on $CODEX_HOME (unlike the Claude side's cc-watcher.js). The Codex Config page subscribes and refetches automatically.

{ "type": "codex_config_changed", "data": { "source": "dashboard", "action": "write", "scope": "user", "type": "rules", "name": "default" } }
{ "type": "codex_config_changed", "data": { "source": "dashboard", "action": "add", "type": "mcp", "name": "docs" } }

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.