4bc77ded17
Adds a "Switch profile" button left of Backups on /settings?tool=claude-code. A *profile* is one Anthropic account - one quota - saved under a name, so the local `claude` CLI can be pointed at a different account without disturbing anything else. A switch swaps exactly two identity artifacts and nothing more: `claudeAiOauth` inside `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` inside the `.claude.json` config file(s). Those files are key-merged in place, and only where they already exist, 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` stays put too: those are the machine's MCP server tokens, not account identity. Profiles are one JSON file each under `<CLAUDE_HOME>/agenthub-profiles/` (dir 0700, files 0600, atomic temp+rename) and hold live OAuth tokens, so no route ever returns token material - `GET /profiles` answers identity metadata only. 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 snapshot what the CLI wrote". `activateProfile` re-snapshots the outgoing account first so tokens Claude Code refreshed since its last save are not lost, and refuses (`EUNSAVED`) to switch away from an account that was never saved - that would discard the only copy of its credentials. A switch applies to new sessions only, since a running session rewrites the config on exit. Routes: GET/POST/DELETE /api/cc-config/profiles, POST /profiles/activate. Verified: server 1420/1420 (14 new, incl. token-free responses and 0600/0700 permissions), client 459/459.
2110 lines
78 KiB
Markdown
2110 lines
78 KiB
Markdown
# API Reference
|
||
|
||
Complete REST API and WebSocket documentation for Agent Dashboard.
|
||
|
||
---
|
||
|
||
## Table of Contents
|
||
|
||
- [Overview](#overview)
|
||
- [Authentication](#authentication)
|
||
- [Base URL](#base-url)
|
||
- [REST API](#rest-api)
|
||
- [Sessions](#sessions)
|
||
- [Agents](#agents)
|
||
- [Tools](#tools)
|
||
- [Metrics](#metrics)
|
||
- [Pricing](#pricing)
|
||
- [Lanes](#lanes)
|
||
- [Notifications](#notifications)
|
||
- [Remote Data Sources](#remote-data-sources)
|
||
- [WebSocket API](#websocket-api)
|
||
- [Error Handling](#error-handling)
|
||
- [Rate Limiting](#rate-limiting)
|
||
- [Pagination](#pagination)
|
||
- [Examples](#examples)
|
||
|
||
---
|
||
|
||
## Overview
|
||
|
||
The Agent Dashboard API provides programmatic access to Claude Code session monitoring data.
|
||
|
||
```mermaid
|
||
graph LR
|
||
Client[API Client] -->|HTTP/HTTPS| REST[REST API<br/>:4820/api/*]
|
||
Client -->|WebSocket| WS[WebSocket<br/>:4820/ws]
|
||
|
||
REST --> DB[(SQLite)]
|
||
WS --> Broadcast[Real-time<br/>Broadcasts]
|
||
|
||
style REST fill:#10B981
|
||
style WS fill:#F59E0B
|
||
style DB fill:#003B57,color:#fff
|
||
```
|
||
|
||
**Protocols:**
|
||
- **REST API** - HTTP/JSON for queries and mutations
|
||
- **WebSocket** - Real-time event streaming
|
||
|
||
---
|
||
|
||
## Authentication
|
||
|
||
The server is **local-first** and is hardened to keep the dashboard off the network by default (see GHSA-gr74-4xfh-6jw9). The trust boundary is the loopback bind, layered with origin and host checks:
|
||
|
||
- **Loopback bind by default** — the server binds `127.0.0.1`, so it is not network-reachable out of the box. Operators opt into a wider bind with `DASHBOARD_HOST` (e.g. `DASHBOARD_HOST=0.0.0.0` for LAN access), which logs a startup warning.
|
||
- **CORS restricted to loopback origins** — cross-origin web pages cannot read API responses. Requests with no `Origin` (curl, server-to-server) still work.
|
||
- **Host-header allowlist** — both HTTP requests and WebSocket upgrades are checked against an allowlist to block DNS-rebinding. Add extra LAN names (when you bind beyond loopback) via `DASHBOARD_ALLOWED_HOSTS` (comma-separated).
|
||
|
||
For deliberate LAN exposure, set `DASHBOARD_HOST` to a non-loopback address and list the names clients use in `DASHBOARD_ALLOWED_HOSTS`.
|
||
|
||
### Optional token (`DASHBOARD_TOKEN`)
|
||
|
||
Authentication is **off by default** (the loopback bind is the trust boundary). When `DASHBOARD_TOKEN` is set, every `/api/*` request **and** the WebSocket must present the token. It is strongly recommended whenever you bind beyond loopback. Pass it any of these ways:
|
||
|
||
- `Authorization: Bearer <token>` header
|
||
- `x-dashboard-token: <token>` header
|
||
- `?token=<token>` query parameter
|
||
|
||
These paths stay exempt even when a token is configured: `/api/health`, `/api/openapi.json`, `/api/docs`, and `/api/hooks` (local Claude Code hook ingestion). Requests that fail the check get `401` with error code `EUNAUTHORIZED`.
|
||
|
||
`GET /api/settings/info` includes `server.version` (the running dashboard release). Pair with `agenthub version` or the Settings About panel to confirm client and server builds match after deploy.
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant Client
|
||
participant API
|
||
participant Auth
|
||
participant Resource
|
||
|
||
Client->>API: Request + DASHBOARD_TOKEN
|
||
API->>Auth: Validate token (if configured)
|
||
Auth-->>API: Valid
|
||
API->>Resource: Fetch Data
|
||
Resource-->>API: Return Data
|
||
API-->>Client: 200 OK + Data
|
||
```
|
||
|
||
---
|
||
|
||
## Base URL
|
||
|
||
```
|
||
http://localhost:4820
|
||
```
|
||
|
||
For production, use HTTPS:
|
||
|
||
```
|
||
https://dashboard.example.com
|
||
```
|
||
|
||
---
|
||
|
||
## REST API
|
||
|
||
### Lanes
|
||
|
||
Lane mutations (`POST /api/lanes/ensure`, `POST /api/lanes/worktree`,
|
||
`PATCH /api/lanes/:id`, confirmed
|
||
actions, the runtime routes `POST /api/lanes/:id/up`, `/down` and
|
||
`/hook/:name`, and `DELETE /api/lanes/:id`) enforce the same loopback same-origin
|
||
guard as `/api/run`: browser requests must originate from `localhost`,
|
||
`127.0.0.1`, `::1`, or `0.0.0.0`; CLI and curl requests without `Origin` pass.
|
||
|
||
Every lane object returned by `GET /api/lanes` and `GET /api/lanes/:id`
|
||
additionally carries the server's stage-detection heuristic (see
|
||
`docs/LANES.md#stage-detection` for the full rules, and
|
||
`server/lib/stage-detect.js` for the matcher):
|
||
|
||
- **`detected_stage`** — stage id inferred from ingested tool events, or
|
||
`null` if no signal has been seen. Independent of the agent's own declared
|
||
`stage`.
|
||
- **`detected_signal`** — the capped (≤120 char) tool-event string that
|
||
produced `detected_stage`; `null` when `detected_stage` is `null`.
|
||
- **`detected`** (boolean, on each entry of `pipeline_nodes`) — `true` for the
|
||
inferred node and any node before it that carries no declared record.
|
||
Decorates that node's existing `state`; **never** upgrades it to `done` —
|
||
an inferred stage is never evidence.
|
||
|
||
#### Find or adopt the lane owning a directory
|
||
|
||
```http
|
||
POST /api/lanes/ensure
|
||
```
|
||
|
||
```json
|
||
{ "cwd": "/absolute/path/to/work", "title": "App package", "pipeline": "ship-feature", "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
|
||
|
||
```http
|
||
PATCH /api/lanes/:id
|
||
```
|
||
|
||
Partially updates a lane. This route is same-origin guarded because its patch
|
||
can set `run_id`.
|
||
|
||
`pipeline` is patchable, which is how a lane moves between templates after
|
||
creation (`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
|
||
|
||
```http
|
||
POST /api/lanes/worktree
|
||
```
|
||
|
||
Creates a dashboard-owned lane from an existing absolute git repository. The
|
||
request returns immediately with `202` and `lane.status: "provisioning"`; the
|
||
dashboard completes the `git worktree add` in the background and broadcasts the
|
||
existing `lane_update` message when the lane becomes `idle` or `failed`.
|
||
|
||
```json
|
||
{
|
||
"sourceRepo": "/absolute/path/to/repository",
|
||
"title": "Criteria form",
|
||
"base": "main",
|
||
"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
|
||
|
||
```http
|
||
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:
|
||
|
||
```json
|
||
{
|
||
"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
|
||
|
||
```http
|
||
GET /api/lanes/:id/preflight?action=reset|remove|purge
|
||
```
|
||
|
||
Returns the exact facts a user must confirm. `reset` and `remove` return
|
||
`head`, `dirty`, `untracked`, `unpushed`, `database`, `blocked`, and `warnings`;
|
||
`purge` returns `sessions`, `events`, `tokenRows`,
|
||
`bytesEstimate`, and `activeSessionSkipped`. `expect` is required: reset/remove
|
||
must include all of `head`, `dirty`, `untracked`, and `unpushed` (not `database`
|
||
— see below); purge must
|
||
include `sessions`, `events`, and `tokenRows`. Missing or incomplete
|
||
confirmation facts return `400 EEXPECT`.
|
||
|
||
`database` is the name a `reset` (unless `keepDb: true`) or `remove` will drop
|
||
— `null` when the lane has no slot yet or its profile declares no `DB_PREFIX`.
|
||
It is not part of the staleness-checked `expect` set: it is derived from the
|
||
lane's slot and profile, not from mutable git/session state, so it cannot go
|
||
stale between preflight and the action. See
|
||
`docs/LANES.md#data-isolation-database-redis-and-env-a2`.
|
||
|
||
`blocked` reports conditions that affect the action: `adopted`
|
||
(not a managed worktree), `missing` (directory gone), `unreadable` (directory
|
||
exists but git failed against it), and `unpushed-commits` (unpushed count > 0
|
||
— the only one `force: true` overrides). **Which of them actually prevent the
|
||
action depends on the action.** They all block `reset`. None of them blocks
|
||
`remove`: the server forgets an adopted lane's row, prunes a hand-deleted
|
||
worktree, and for an unreadable one attempts `git worktree remove --force` —
|
||
if git itself refuses (e.g. a corrupt worktree `.git` pointer), the server
|
||
deregisters it directly from the source repo's bookkeeping instead, without
|
||
ever touching the directory. So for `remove` they are context rather than
|
||
refusals — a client must not disable confirmation on them.
|
||
`warnings` holds purely informational
|
||
facts that never gate the action: currently just `no-remote` (no git remote
|
||
configured — nothing here is backed up remotely, but the action proceeds).
|
||
|
||
`unpushed` counts the commits the action would actually discard: commits on no
|
||
remote when remotes exist, otherwise the commits ahead of the lane's
|
||
`base_branch` (its own work), falling back to the total commit count only for a
|
||
lane with no base at all. A freshly provisioned worktree therefore reports `0`,
|
||
not the whole repository's history.
|
||
|
||
#### Reset, remove, or purge a lane
|
||
|
||
```http
|
||
POST /api/lanes/:id/reset
|
||
POST /api/lanes/:id/remove
|
||
POST /api/lanes/:id/purge
|
||
```
|
||
|
||
All three actions require `{ "confirm": true }` and a complete `expect` object,
|
||
and run under the lane lock. They stop the lane run and wait for the spawned
|
||
child's actual `exit` event (including the SIGKILL escalation window) before
|
||
performing the action. A failed spawn is treated as already exited because no
|
||
child process started or can touch the lane directory. A child that does not
|
||
exit returns `500 ERUNTIMEOUT` and does not run git. `reset` and `remove` additionally require
|
||
`{ "force": true }` when preflight reports `unpushed > 0`.
|
||
|
||
```json
|
||
{
|
||
"confirm": true,
|
||
"force": true,
|
||
"keepDb": false,
|
||
"expect": {
|
||
"head": "9b3e74a",
|
||
"dirty": 4,
|
||
"untracked": 11,
|
||
"unpushed": 2
|
||
}
|
||
}
|
||
```
|
||
|
||
For `purge`, `expect` contains `sessions`, `events`, and `tokenRows` from its
|
||
preflight. If any echoed fact changed, the action returns `409 ESTALE` with its
|
||
`expected` and `current` diagnostics, without resetting, removing, or purging.
|
||
Missing `force` for unpushed work returns `409 EUNPUSHED`.
|
||
`reset` restores the managed worktree's feature branch from its base, cleans
|
||
untracked files but preserves ignored files, clears the lane stage state, and —
|
||
when the lane's profile declares data isolation (`docs/LANES.md#data-isolation-database-redis-and-env-a2`)
|
||
— refreshes `.env`, re-runs `bootstrap`, clears `LANE_DIRS`, and drops +
|
||
recreates + migrates + reseeds the database, unless `keepDb: true` is passed
|
||
(reset only; skips that whole block, leaving the database untouched).
|
||
`remove` removes the managed git worktree, prunes it, deletes its feature branch,
|
||
drops the lane's database and its `_test` sibling (best-effort; never for an
|
||
adopted lane), then deletes the lane row. `purge` returns the deleted counts:
|
||
|
||
```json
|
||
{
|
||
"ok": true,
|
||
"purged": { "sessions": 2, "events": 14, "tokenRows": 2 }
|
||
}
|
||
```
|
||
|
||
Only dashboard-managed worktrees can be reset. For `remove`, a managed lane
|
||
uses guarded worktree removal and branch deletion; an adopted lane only has its
|
||
dashboard row forgotten, and its directory is never changed. `400` preserves
|
||
`ENOTMANAGED`, `EOUTSIDEROOT`, and `ENOTWORKTREE` guard failures on the managed
|
||
destroy path. Git failures return `500` with their git `stderr` in `error.stderr`.
|
||
|
||
#### Read a lane's runtime
|
||
|
||
```http
|
||
GET /api/lanes/:id/runtime
|
||
```
|
||
|
||
What is actually running for this lane, recomputed on every call from pid files
|
||
and port probes — never cached, because a process can die without telling
|
||
anyone. See `docs/LANES.md#lane-runtime-running-a-lanes-own-stack`.
|
||
|
||
```json
|
||
{
|
||
"available": true,
|
||
"provisioned": true,
|
||
"slot": 3,
|
||
"kind": "managed",
|
||
"hooks": ["bootstrap", "boot", "health"],
|
||
"profileDir": "/work/myapp/.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
|
||
|
||
```http
|
||
POST /api/lanes/:id/up { "build": true }
|
||
POST /api/lanes/:id/down
|
||
```
|
||
|
||
`up` returns **202** and boots in the background, because a build can run for
|
||
minutes. Progress streams as `lane_hook_output` WebSocket messages and the
|
||
attempt finishes with a `lane_runtime` message carrying the fresh facts (or an
|
||
`error`). `build: false` passes `--no-build` to the `boot` hook.
|
||
|
||
`up` runs the profile's `boot` then `health` hooks; it does **not** run
|
||
`bootstrap`. A failing `health` leaves the processes running so their logs
|
||
remain readable, and records `EUNHEALTHY` in the runtime's `lastError`.
|
||
|
||
`down` is synchronous, idempotent, and a no-op for a lane that was never up:
|
||
|
||
```json
|
||
{ "ok": true, "killed": [40213, 40219], "runtime": { "…": "…" } }
|
||
```
|
||
|
||
Both write only `slot` and `ports` on the lane row. Neither writes `stage`,
|
||
`status`, or `notes` — in 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
|
||
|
||
```http
|
||
POST /api/lanes/:id/hook/:name { "args": ["--scope", "smoke"] }
|
||
```
|
||
|
||
Runs one of the profile's hooks — the surface a driving session uses for
|
||
`ci-gate`, `e2e`, `migrate` and friends. Returns **202**; output streams as
|
||
`lane_hook_output` and completion arrives as `lane_hook_result` with the exit
|
||
`code`.
|
||
|
||
`:name` is checked against a fixed allowlist (`bootstrap`, `boot`, `health`,
|
||
`migrate`, `seed`, `ci-gate`, `e2e`, `regen`, `db-create`, `db-drop`) **before**
|
||
anything is spawned, and `args` travels as an array of strings straight into
|
||
argv — neither is ever joined into a command string. An unknown name returns
|
||
`400 ENOHOOK` with the allowed list; a lane with no slot returns `409 ENOSLOT`.
|
||
|
||
#### Tail a lane's log
|
||
|
||
```http
|
||
GET /api/lanes/:id/logs/:svc?tail=65536
|
||
```
|
||
|
||
```json
|
||
{ "available": true, "svc": "api", "size": 20481, "truncated": false, "text": "…" }
|
||
```
|
||
|
||
`tail` is the number of trailing bytes (default 64 KiB, capped at 1 MiB). The
|
||
resolved path is confined to the lane's log directory after `realpath`, so a
|
||
name from the request can never escape it; anything else is `404 ENOLOG`. A lane
|
||
with no slot returns `{"available": false}`.
|
||
|
||
|
||
#### Lane features
|
||
|
||
Per-feature state and archive (`server/lib/lane-features.js`) — a lane's
|
||
`stage`/`status`/etc. is always the LIVE view of whichever feature it's
|
||
currently on; these routes read and switch between a lane's feature history.
|
||
|
||
```http
|
||
GET /api/lanes/:id/features
|
||
```
|
||
|
||
Every feature this lane has ever activated, archived or live, most recently
|
||
touched first:
|
||
|
||
```json
|
||
{
|
||
"features": [
|
||
{
|
||
"id": 5, "lane_id": 12, "slug": "auth-redesign", "title": "auth-redesign",
|
||
"branch": "feat/auth", "pipeline": "default", "stage": "implement",
|
||
"stage_since": "2026-08-04T09:00:00.000Z", "status": "running",
|
||
"gate_decision": null, "ci_status": null, "qc_dev": null,
|
||
"stages": {}, "links": {}, "notes": null, "archived_at": null,
|
||
"created_at": "2026-08-04T09:00:00.000Z", "updated_at": "2026-08-04T09:00:00.000Z",
|
||
"pipeline_name": "Default", "pipeline_nodes": [ /* same shape as a live lane's */ ], "progress": 40
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
Each row is the hydrated `lane_features` row (`stages`/`links` parsed from
|
||
JSON) plus `pipeline_name`/`pipeline_nodes`/`progress`, computed the exact
|
||
same way a live lane's are — the client renders an archived feature with the
|
||
same `PipelineMap` component, no special-casing. `archived_at` is `null` for
|
||
the currently-active feature, an ISO timestamp for every past one. `slug` is
|
||
always the **canonicalized** form (drops a leading `feat/`, turns `/` and
|
||
whitespace into `-`, keeps `[A-Za-z0-9._-]`, preserves case — **not the same
|
||
rule as `worktree.js:slugify`**, which lowercases everything).
|
||
|
||
```http
|
||
GET /api/lanes/:id/features/:slug
|
||
```
|
||
|
||
One feature by its canonicalized slug, same shape as an entry above, wrapped
|
||
as `{ "feature": {...} }`. `404 ENOFEATURE` if that slug was never activated
|
||
on this lane; `404 ENOLANE` if the lane itself doesn't exist.
|
||
|
||
```http
|
||
POST /api/lanes/:id/features/activate
|
||
{ "slug": "feat/Auth Redesign", "title": "Auth redesign (optional)" }
|
||
```
|
||
|
||
Switches the lane to a feature by slug:
|
||
- If a **different** feature is currently active, it is archived first with
|
||
its exact live state (stage, status, gate/CI fields, stages, links, notes)
|
||
— its own `title` is never overwritten by this.
|
||
- If the target slug was activated before, its saved state is restored onto
|
||
the live lane (stage, status, gate/CI fields, stages, links, notes) — this
|
||
is what makes switching back to a past feature resume where it left off.
|
||
- If the slug is new, a fresh feature row is created (`title` defaults to the
|
||
slug itself when omitted).
|
||
- Re-activating the **currently** active slug is a no-op.
|
||
|
||
Response: `{ "lane": <same shape GET /api/lanes/:id returns>, "feature": <same shape as the list above> }`.
|
||
|
||
- **200** — activated (or already active).
|
||
- **400** `{ "error": { "code": "EBADSLUG", "message": "slug is required" } }` — missing/empty `slug`, or the canonicalized result is empty (e.g. `"feat/"` alone).
|
||
- **404** `{ "error": { "code": "ENOLANE", "message": "lane not found" } }`.
|
||
|
||
|
||
#### Lane proof gallery
|
||
|
||
Proof gallery (QC screenshots) captured at `<lane.cwd>/.playwright-mcp/proof/<slug>/<phase-group>/` and grouped by feature slug (the same slug from per-feature state). Read-only access and deletion are path-contained: every proof file is resolved, realpath'd, and checked to be inside the lane's proof root before serving or deleting.
|
||
|
||
```http
|
||
GET /api/lanes/:id/proof
|
||
```
|
||
|
||
List every feature with proof (or an activated-but-not-yet-shot feature from `lane_features`), most recently touched first:
|
||
|
||
```json
|
||
{
|
||
"features": [
|
||
{
|
||
"slug": "auth-redesign",
|
||
"groups": {
|
||
"desktop": ["01-login.png", "02-signup.png"],
|
||
"mobile": ["01-login.png"]
|
||
},
|
||
"ticket_report": "auth-redesign/ticket/REPORT.html",
|
||
"mtime": 1722755400
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
Each feature has:
|
||
- **`slug`** (string) — canonicalized feature slug
|
||
- **`groups`** (object) — `{ "phase": ["image1.png", "image2.png"] }` — phase-groups with their images
|
||
- **`ticket_report`** (string) — relative path to ticket report HTML, or empty if none exists
|
||
- **`mtime`** (number) — Unix seconds of the most recent file touched in this feature's proof tree (0 if the feature has no proof yet)
|
||
|
||
**Status codes:**
|
||
- **200** — success
|
||
- **404** `{ "error": { "code": "ENOLANE", "message": "lane not found" } }` — lane doesn't exist
|
||
|
||
```http
|
||
GET /api/lanes/:id/proof/:slug/:group/:file
|
||
```
|
||
|
||
Fetch a single proof file (PNG, JPG, or HTML). The file is resolved with path containment checks before serving:
|
||
|
||
**Status codes:**
|
||
- **200** — file content, `Content-Type` set to `image/png`, `image/jpeg`, or `text/html`
|
||
- **404** — lane, slug, group, or file not found, or path escapes containment
|
||
|
||
```http
|
||
DELETE /api/lanes/:id/proof/:slug
|
||
```
|
||
|
||
Delete proof at one of three granularities: request body `{ "group": "phase-name", "images": ["file1.png"] }` deletes specific images from a phase, `{ "group": "phase-name" }` deletes an entire phase, or empty `{}` deletes all phases **except** `ticket/`.
|
||
|
||
```json
|
||
{ "deleted": 3 }
|
||
```
|
||
|
||
**Status codes:**
|
||
- **200** — success, returns count of deleted files
|
||
- **400** `{ "error": { "code": "EBADPATH", "message": "…" } }` — invalid slug, group, or image name; or attempt to delete `ticket/`
|
||
- **404** `{ "error": { "code": "ENOLANE", "message": "lane not found" } }` — lane doesn't exist
|
||
- **404** `{ "error": { "code": "ENOFEATURE", "message": "…" } }` — slug doesn't exist
|
||
|
||
```http
|
||
POST /api/lanes/:id/proof-link
|
||
```
|
||
|
||
Converge the clone-root `proof/` directory (if it exists at `<lane.cwd>/proof`) onto the canonical `.playwright-mcp/proof`. This is an explicit operation, never automatic — exposed only as `agenthub lanes proof-link`. Idempotent.
|
||
|
||
**Response:**
|
||
|
||
```json
|
||
{ "linked": true }
|
||
```
|
||
|
||
- **`linked`** (boolean) — `true` if the link was created or fixed, `false` if the symlink already pointed to the right place or a collision was avoided
|
||
|
||
**Status codes:**
|
||
- **200** — success
|
||
- **404** `{ "error": { "code": "ENOLANE", "message": "lane not found" } }` — lane doesn't exist
|
||
|
||
#### Pipeline pass statistics
|
||
|
||
```http
|
||
GET /api/lanes/pipeline-stats
|
||
```
|
||
|
||
How often each pipeline node was actually passed, aggregated over every lane
|
||
**and** every archived feature (an archived feature is a finished run of a
|
||
pipeline, so it carries most of the history once a lane starts its next
|
||
feature). Grouped by pipeline template, busiest first.
|
||
|
||
**Query parameters:**
|
||
- **`lane`** — lane id; narrows the aggregation to that lane and its own
|
||
archived features. Omit (or pass `all`) for every lane.
|
||
|
||
```json
|
||
{
|
||
"pipelines": [
|
||
{
|
||
"id": "default",
|
||
"name": "Default feature pipeline",
|
||
"runs": 4,
|
||
"nodes": [
|
||
{ "id": "plan", "label": "plan", "icon": "🧭", "gate": false,
|
||
"entered": 4, "passed": 4, "withEvidence": 4, "failed": 0 }
|
||
]
|
||
}
|
||
],
|
||
"lanes": [{ "id": 1, "title": "api lane", "cwd": "/w/api" }]
|
||
}
|
||
```
|
||
|
||
- **`lanes`** — every lane, for the picker. Never narrowed by `lane`, so the
|
||
response always offers the way back to the other lanes.
|
||
- **`runs`** — lanes plus archived features recorded against this template.
|
||
- **`entered`** — stage records that resolve onto this node (aliases included).
|
||
- **`failed`** — records whose `result` is `"fail"`; **`passed`** is the rest,
|
||
so `passed + failed === entered`.
|
||
- **`withEvidence`** — the subset of passes that carry evidence. Tracked apart
|
||
from `passed` because a claim and a proof are not the same fact — the same
|
||
distinction the pipeline map draws between `done` and `passed-no-evidence`.
|
||
|
||
**Status codes:**
|
||
- **200** — success (an empty `pipelines` array when no lane has recorded a
|
||
stage yet, or when `lane` names an id that no longer exists)
|
||
- **400** `{ "error": { "code": "EBADLANE", "message": "lane must be an id" } }` — `lane` is not a positive integer
|
||
|
||
### Workflows
|
||
|
||
#### Skill and plugin usage
|
||
|
||
```http
|
||
GET /api/workflows/skill-usage?status=active|completed
|
||
```
|
||
|
||
Skill invocations counted from `Skill` tool events — `PreToolUse` rows only,
|
||
since every call also emits a `PostToolUse` and counting both would double
|
||
every number. `status` filters by session lifecycle, same convention as
|
||
`GET /api/workflows`; omit it (or pass `all`) for no filter.
|
||
|
||
```json
|
||
{
|
||
"skills": [
|
||
{ "name": "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
|
||
|
||
```http
|
||
GET /api/locks
|
||
```
|
||
|
||
Every currently-held lock:
|
||
|
||
```json
|
||
{
|
||
"locks": [{ "name": "build", "holder": "lane3", "since": 1722702012, "ageSec": 41 }]
|
||
}
|
||
```
|
||
|
||
`since` is a Unix **seconds** timestamp; `ageSec` is derived at read time. An
|
||
empty array means nothing is held.
|
||
|
||
#### Acquire a lock
|
||
|
||
```http
|
||
POST /api/locks/:name/acquire
|
||
{ "holder": "lane3" }
|
||
```
|
||
|
||
`holder` is **required** — this route does not default it; `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
|
||
|
||
```http
|
||
POST /api/locks/:name/release
|
||
{ "holder": "lane3" }
|
||
```
|
||
|
||
- **200** `{ "ok": true }` — released.
|
||
- **409** `{ "error": { "code": "ENOTHOLDER", "message": "...", "currentHolder": "lane1" } }` — `holder` doesn't match the current owner; a release never trusts its caller, same rule every other destructive path in this project follows.
|
||
- **404** `{ "error": { "code": "ENOLOCK", "message": "no such lock: build" } }` — nothing by that name is held.
|
||
|
||
### Sessions
|
||
|
||
#### List Sessions
|
||
|
||
```http
|
||
GET /api/sessions
|
||
```
|
||
|
||
Returns all sessions, ordered by most recent activity.
|
||
|
||
**Query Parameters:**
|
||
|
||
| Parameter | Type | Default | Description |
|
||
|-----------|------|---------|-------------|
|
||
| `limit` | integer | 50 | Maximum sessions to return (1-1000) |
|
||
| `offset` | integer | 0 | Pagination offset |
|
||
| `status` | string | - | Filter by persisted status: `active`, `completed`, `error`, `abandoned`. The UI **Waiting** state is derived from the `awaiting_input_since` column and is not a queryable enum — filter `status=active` and inspect `awaiting_input_since` (non-null = Waiting) |
|
||
| `sources` | string | - | Comma-separated data-source ids to include (the built-in local history is `local`; remote SSH machines use their `remote_sources.id`). Omit for all sources. Also accepted on `/api/events`, `/api/agents`, `/api/stats`, `/api/analytics`, and `/api/pricing/cost`. See [Remote Data Sources](#remote-data-sources) |
|
||
|
||
**Example Request:**
|
||
|
||
```bash
|
||
curl http://localhost:4820/api/sessions?limit=10&status=active
|
||
```
|
||
|
||
**Example Response:**
|
||
|
||
```json
|
||
{
|
||
"sessions": [
|
||
{
|
||
"id": 1,
|
||
"session_id": "sess_abc123",
|
||
"model": "claude-sonnet-4",
|
||
"status": "active",
|
||
"total_cost": 1.23,
|
||
"agent_count": 3,
|
||
"tool_count": 12,
|
||
"created_at": "2024-03-18T12:00:00Z",
|
||
"updated_at": "2024-03-18T14:30:00Z"
|
||
}
|
||
],
|
||
"total": 42,
|
||
"limit": 10,
|
||
"offset": 0
|
||
}
|
||
```
|
||
|
||
**Response Schema:**
|
||
|
||
```mermaid
|
||
classDiagram
|
||
class SessionListResponse {
|
||
+Session[] sessions
|
||
+number total
|
||
+number limit
|
||
+number offset
|
||
}
|
||
|
||
class Session {
|
||
+string id
|
||
+string name
|
||
+string status "active|completed|error|abandoned"
|
||
+string cwd
|
||
+string model
|
||
+string started_at
|
||
+string ended_at
|
||
+string updated_at
|
||
+string awaiting_input_since "null unless Waiting"
|
||
+string awaiting_reason "notification|stop|session_start|interrupted; null unless Waiting"
|
||
+number cost
|
||
+number agent_count
|
||
}
|
||
|
||
SessionListResponse --> Session
|
||
```
|
||
|
||
---
|
||
|
||
#### Get Session
|
||
|
||
```http
|
||
GET /api/sessions/:id
|
||
```
|
||
|
||
Returns single session details.
|
||
|
||
**Path Parameters:**
|
||
|
||
| Parameter | Type | Description |
|
||
|-----------|------|-------------|
|
||
| `id` | string | Session ID (e.g., `sess_abc123`) |
|
||
|
||
**Example Request:**
|
||
|
||
```bash
|
||
curl http://localhost:4820/api/sessions/sess_abc123
|
||
```
|
||
|
||
**Example Response:**
|
||
|
||
```json
|
||
{
|
||
"session": {
|
||
"id": 1,
|
||
"session_id": "sess_abc123",
|
||
"model": "claude-sonnet-4",
|
||
"status": "active",
|
||
"total_cost": 1.23,
|
||
"created_at": "2024-03-18T12:00:00Z",
|
||
"updated_at": "2024-03-18T14:30:00Z"
|
||
}
|
||
}
|
||
```
|
||
|
||
**Error Responses:**
|
||
|
||
| Code | Description |
|
||
|------|-------------|
|
||
| 404 | Session not found |
|
||
| 500 | Internal server error |
|
||
|
||
---
|
||
|
||
#### Get Session Stats
|
||
|
||
```http
|
||
GET /api/sessions/:id/stats
|
||
```
|
||
|
||
Returns aggregated counts powering the Session Detail overview panel. All aggregation runs in SQL — the response is cheap to compute even for sessions with tens of thousands of events.
|
||
|
||
**Path Parameters:**
|
||
|
||
| Parameter | Type | Description |
|
||
|-----------|------|-------------|
|
||
| `id` | string | Session ID |
|
||
|
||
**Example Request:**
|
||
|
||
```bash
|
||
curl http://localhost:4820/api/sessions/sess_abc123/stats
|
||
```
|
||
|
||
**Example Response:**
|
||
|
||
```json
|
||
{
|
||
"session_id": "sess_abc123",
|
||
"total_events": 14082,
|
||
"events_by_type": [
|
||
{ "event_type": "PreToolUse", "count": 5210 },
|
||
{ "event_type": "PostToolUse", "count": 5208 }
|
||
],
|
||
"tools_used": [
|
||
{ "tool_name": "Bash", "count": 1842 },
|
||
{ "tool_name": "Read", "count": 1340 }
|
||
],
|
||
"error_count": 12,
|
||
"first_event_at": "2026-04-26T18:59:00.000Z",
|
||
"last_event_at": "2026-04-29T21:30:14.000Z",
|
||
"agents": {
|
||
"total": 12,
|
||
"main": 1,
|
||
"subagent": 11,
|
||
"compaction": 5,
|
||
"by_status": { "completed": 11, "working": 1 }
|
||
},
|
||
"subagent_types": [
|
||
{ "subagent_type": "Explore", "count": 4 }
|
||
],
|
||
"tokens": {
|
||
"input_tokens": 1376,
|
||
"output_tokens": 760304,
|
||
"cache_read_tokens": 337641891,
|
||
"cache_write_tokens": 5126047
|
||
}
|
||
}
|
||
```
|
||
|
||
**Error Responses:**
|
||
|
||
| Code | Description |
|
||
|------|-------------|
|
||
| 404 | Session not found |
|
||
| 500 | Internal server error |
|
||
|
||
---
|
||
|
||
#### Get Session Agents
|
||
|
||
```http
|
||
GET /api/sessions/:id/agents
|
||
```
|
||
|
||
Returns all agents for a session.
|
||
|
||
**Path Parameters:**
|
||
|
||
| Parameter | Type | Description |
|
||
|-----------|------|-------------|
|
||
| `id` | string | Session ID |
|
||
|
||
**Example Request:**
|
||
|
||
```bash
|
||
curl http://localhost:4820/api/sessions/sess_abc123/agents
|
||
```
|
||
|
||
**Example Response:**
|
||
|
||
```json
|
||
{
|
||
"agents": [
|
||
{
|
||
"id": "sess_abc123-main",
|
||
"session_id": "sess_abc123",
|
||
"name": "Main Agent - my-project",
|
||
"type": "main",
|
||
"subagent_type": null,
|
||
"status": "idle",
|
||
"current_tool": null,
|
||
"task": null,
|
||
"started_at": "2024-03-18T12:00:00Z",
|
||
"ended_at": null,
|
||
"updated_at": "2024-03-18T12:05:00Z",
|
||
"parent_agent_id": null,
|
||
"awaiting_input_since": "2024-03-18T12:05:00Z",
|
||
"awaiting_reason": "stop",
|
||
"cost": 0
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
> **Note on `cost`** — `/api/agents` and `/api/sessions/:id/agents` attach a `cost` (USD) to each agent: the agent's **own** cost, computed server-side from the per-agent token buckets stored in `agents.metadata.tokens` and priced at the current pricing rules (at the agent's start date, so promo/standard cutovers apply — see [Pricing](#pricing)). It is `0` for main agents (whose cost is the session total, reported by `/api/pricing/cost/:sessionId`), for compaction pseudo-agents, and for any subagent whose transcript is unavailable. This lets a subagent card show only what that subagent spent instead of the whole session's total.
|
||
|
||
> **Note on `status` vs Waiting** — agents are persisted with one of `idle | connected | working | completed | error`. The yellow **Waiting** badge surfaced in the dashboard is a UI overlay derived from `awaiting_input_since` being non-null on a non-terminal agent (typically `idle` after a `Stop`, or `connected` right after `SessionStart`). Filter `?status=idle` on `/api/agents` and inspect `awaiting_input_since` to enumerate currently-waiting main agents.
|
||
|
||
---
|
||
|
||
### Agents
|
||
|
||
#### Get Agent
|
||
|
||
```http
|
||
GET /api/agents/:id
|
||
```
|
||
|
||
Returns single agent details.
|
||
|
||
**Path Parameters:**
|
||
|
||
| Parameter | Type | Description |
|
||
|-----------|------|-------------|
|
||
| `id` | string | Agent ID (e.g., `agent_xyz789`) |
|
||
|
||
**Example Request:**
|
||
|
||
```bash
|
||
curl http://localhost:4820/api/agents/agent_xyz789
|
||
```
|
||
|
||
**Example Response:**
|
||
|
||
```json
|
||
{
|
||
"agent": {
|
||
"id": 1,
|
||
"agent_id": "agent_xyz789",
|
||
"session_id": "sess_abc123",
|
||
"agent_type": "explore",
|
||
"status": "completed",
|
||
"current_tool": null,
|
||
"input_tokens": 1500,
|
||
"output_tokens": 800,
|
||
"cost": 0.45,
|
||
"created_at": "2024-03-18T12:00:00Z",
|
||
"updated_at": "2024-03-18T12:05:00Z"
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
#### Get Agent Tools
|
||
|
||
```http
|
||
GET /api/agents/:id/tools
|
||
```
|
||
|
||
Returns tool executions for an agent.
|
||
|
||
**Path Parameters:**
|
||
|
||
| Parameter | Type | Description |
|
||
|-----------|------|-------------|
|
||
| `id` | string | Agent ID |
|
||
|
||
**Example Request:**
|
||
|
||
```bash
|
||
curl http://localhost:4820/api/agents/agent_xyz789/tools
|
||
```
|
||
|
||
**Example Response:**
|
||
|
||
```json
|
||
{
|
||
"tools": [
|
||
{
|
||
"id": 1,
|
||
"agent_id": "agent_xyz789",
|
||
"tool_name": "bash",
|
||
"duration_ms": 1234,
|
||
"success": 1,
|
||
"error_message": null,
|
||
"created_at": "2024-03-18T12:01:00Z"
|
||
},
|
||
{
|
||
"id": 2,
|
||
"agent_id": "agent_xyz789",
|
||
"tool_name": "view",
|
||
"duration_ms": 45,
|
||
"success": 1,
|
||
"error_message": null,
|
||
"created_at": "2024-03-18T12:02:00Z"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
**Tool Execution Flow:**
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant Agent
|
||
participant PreHook as PreToolUse Hook
|
||
participant Tool as Tool Execution
|
||
participant PostHook as PostToolUse Hook
|
||
participant DB as Database
|
||
|
||
Agent->>PreHook: Tool about to execute
|
||
PreHook->>DB: Set current_tool
|
||
|
||
Agent->>Tool: Execute (bash, view, etc.)
|
||
Tool-->>Agent: Result
|
||
|
||
Agent->>PostHook: Tool completed
|
||
PostHook->>DB: Create tool_execution record
|
||
PostHook->>DB: Clear current_tool
|
||
PostHook->>DB: Update token counts + cost
|
||
```
|
||
|
||
---
|
||
|
||
### Tools
|
||
|
||
#### List All Tools
|
||
|
||
```http
|
||
GET /api/tools
|
||
```
|
||
|
||
Returns all tool executions across all sessions.
|
||
|
||
**Query Parameters:**
|
||
|
||
| Parameter | Type | Default | Description |
|
||
|-----------|------|---------|-------------|
|
||
| `limit` | integer | 100 | Max tools to return |
|
||
| `tool_name` | string | - | Filter by tool name |
|
||
| `success` | boolean | - | Filter by success status |
|
||
|
||
**Example Request:**
|
||
|
||
```bash
|
||
curl http://localhost:4820/api/tools?limit=50&tool_name=bash
|
||
```
|
||
|
||
**Example Response:**
|
||
|
||
```json
|
||
{
|
||
"tools": [
|
||
{
|
||
"id": 1,
|
||
"agent_id": "agent_xyz789",
|
||
"tool_name": "bash",
|
||
"duration_ms": 1234,
|
||
"success": 1,
|
||
"error_message": null,
|
||
"created_at": "2024-03-18T12:01:00Z"
|
||
}
|
||
],
|
||
"total": 156
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### Metrics
|
||
|
||
#### Prometheus exposition
|
||
|
||
```
|
||
GET /api/metrics
|
||
```
|
||
|
||
Exposes the dashboard's live counters in the [Prometheus text-exposition format](https://prometheus.io/docs/instrumenting/exposition_formats/) (v0.0.4) so this monitoring dashboard can itself be scraped into Prometheus / Grafana. Read-only. Values are read from the same prepared statements the REST API uses, so they match the UI.
|
||
|
||
Response `Content-Type: text/plain; version=0.0.4; charset=utf-8`.
|
||
|
||
| Metric | Type | Labels | Meaning |
|
||
| --- | --- | --- | --- |
|
||
| `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`):
|
||
|
||
```yaml
|
||
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
|
||
|
||
```http
|
||
GET /api/pricing
|
||
```
|
||
|
||
Returns all pricing rules (default + custom).
|
||
|
||
**Example Request:**
|
||
|
||
```bash
|
||
curl http://localhost:4820/api/pricing
|
||
```
|
||
|
||
**Example Response:**
|
||
|
||
```json
|
||
{
|
||
"rules": [
|
||
{
|
||
"id": 1,
|
||
"pattern": "claude-sonnet-4",
|
||
"input_cost_per_1m": 3.0,
|
||
"output_cost_per_1m": 15.0,
|
||
"is_default": true,
|
||
"created_at": "2024-03-18T12:00:00Z"
|
||
},
|
||
{
|
||
"id": 10,
|
||
"pattern": "gpt-5.1-codex",
|
||
"input_cost_per_1m": 2.5,
|
||
"output_cost_per_1m": 10.0,
|
||
"is_default": false,
|
||
"created_at": "2024-03-18T14:30:00Z"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
**Pricing Rule Matching:**
|
||
|
||
```mermaid
|
||
graph TB
|
||
Model[Model Name<br/>e.g., claude-sonnet-4] --> Match{Match Pattern?}
|
||
|
||
Match -->|Exact Match| Custom[Use Custom Rule]
|
||
Match -->|Substring Match| Default[Use Default Rule]
|
||
Match -->|No Match| Fallback[Use Generic Fallback]
|
||
|
||
Custom --> Calculate[Calculate Cost]
|
||
Default --> Calculate
|
||
Fallback --> Calculate
|
||
|
||
Calculate --> Result[input_cost + output_cost]
|
||
|
||
style Calculate fill:#10B981
|
||
```
|
||
|
||
---
|
||
|
||
#### Create or Update Pricing Rule
|
||
|
||
```http
|
||
PUT /api/pricing
|
||
```
|
||
|
||
Upsert a pricing rule, keyed by `model_pattern`. The same call creates a new rule or updates an existing one (matched on `model_pattern`). Rates are per **million** tokens.
|
||
|
||
**Request Body:**
|
||
|
||
```json
|
||
{
|
||
"model_pattern": "claude-sonnet-5%",
|
||
"display_name": "Claude Sonnet 5",
|
||
"input_per_mtok": 3,
|
||
"output_per_mtok": 15,
|
||
"cache_read_per_mtok": 0.3,
|
||
"cache_write_per_mtok": 3.75,
|
||
"cache_write_1h_per_mtok": 6,
|
||
"fast_input_per_mtok": 0,
|
||
"fast_output_per_mtok": 0,
|
||
|
||
"intro_until": "2026-08-31",
|
||
"intro_input_per_mtok": 2,
|
||
"intro_output_per_mtok": 10,
|
||
"intro_cache_read_per_mtok": 0.2,
|
||
"intro_cache_write_per_mtok": 2.5,
|
||
"intro_cache_write_1h_per_mtok": 4
|
||
}
|
||
```
|
||
|
||
**Fields:**
|
||
|
||
| Field | Type | Constraints |
|
||
|-------|------|-------------|
|
||
| `model_pattern` | string | Required. SQL-style glob; `%` matches any characters (e.g. `claude-opus-4-7%`) |
|
||
| `display_name` | string | Required |
|
||
| `input_per_mtok` / `output_per_mtok` | number | Standard per-MTok rates (default 0) |
|
||
| `cache_read_per_mtok` / `cache_write_per_mtok` / `cache_write_1h_per_mtok` | number | Cache rates (default 0) |
|
||
| `fast_input_per_mtok` / `fast_output_per_mtok` | number | Fast-mode premium rates (default 0) |
|
||
| `intro_until` | string \| null | Optional promo cutoff `YYYY-MM-DD`. Usage **on or before** this date is priced at the `intro_*` rates, after it at the standard rates. Empty/`null` clears the promo (and zeroes the intro rates) |
|
||
| `intro_*_per_mtok` | number | Optional introductory (promo) rates, mirroring the standard fields |
|
||
|
||
The intro block is **optional and backward-compatible**: a request that omits every `intro_*`/`intro_until` field leaves any existing promo untouched, so older clients that send only the standard rates never clobber a promo.
|
||
|
||
**Validation:** every `*_per_mtok` rate present in the body must be a **non-negative finite number** (numeric strings are coerced); a `NaN`, non-numeric, or negative value is rejected with `400 INVALID_INPUT` naming the offending field, and nothing is written. `intro_until` must be a `YYYY-MM-DD` date (or empty/`null` to clear the promo).
|
||
|
||
**Example Request:**
|
||
|
||
```bash
|
||
curl -X PUT http://localhost:4820/api/pricing \
|
||
-H "Content-Type: application/json" \
|
||
-d '{
|
||
"model_pattern": "gpt-5.1-codex",
|
||
"display_name": "GPT-5.1 Codex",
|
||
"input_per_mtok": 2.5,
|
||
"output_per_mtok": 10.0
|
||
}'
|
||
```
|
||
|
||
**Example Response:**
|
||
|
||
```json
|
||
{
|
||
"pricing": {
|
||
"model_pattern": "gpt-5.1-codex",
|
||
"display_name": "GPT-5.1 Codex",
|
||
"input_per_mtok": 2.5,
|
||
"output_per_mtok": 10.0,
|
||
"intro_until": null,
|
||
"updated_at": "2026-07-01T14:30:00Z"
|
||
}
|
||
}
|
||
```
|
||
|
||
**Error Responses:**
|
||
|
||
| Code | Description |
|
||
|------|-------------|
|
||
| 400 | Missing `model_pattern`/`display_name`, or `intro_until` not a `YYYY-MM-DD` date |
|
||
| 500 | Database error |
|
||
|
||
---
|
||
|
||
#### Delete Pricing Rule
|
||
|
||
```http
|
||
DELETE /api/pricing/:pattern
|
||
```
|
||
|
||
Delete custom pricing rule (default rules cannot be deleted).
|
||
|
||
**Path Parameters:**
|
||
|
||
| Parameter | Type | Description |
|
||
|-----------|------|-------------|
|
||
| `pattern` | string | Pattern to delete (URL-encoded) |
|
||
|
||
**Example Request:**
|
||
|
||
```bash
|
||
# Pattern must be URL-encoded
|
||
curl -X DELETE http://localhost:4820/api/pricing/gpt-5.1-codex
|
||
```
|
||
|
||
**Example Response:**
|
||
|
||
```json
|
||
{
|
||
"deleted": true
|
||
}
|
||
```
|
||
|
||
**Error Responses:**
|
||
|
||
| Code | Description |
|
||
|------|-------------|
|
||
| 404 | Pattern not found |
|
||
| 403 | Cannot delete default rule |
|
||
| 500 | Database error |
|
||
|
||
---
|
||
|
||
### Notifications
|
||
|
||
#### Get Session Notifications
|
||
|
||
```http
|
||
GET /api/sessions/:id/notifications
|
||
```
|
||
|
||
Returns notifications for a session.
|
||
|
||
**Path Parameters:**
|
||
|
||
| Parameter | Type | Description |
|
||
|-----------|------|-------------|
|
||
| `id` | string | Session ID |
|
||
|
||
**Example Request:**
|
||
|
||
```bash
|
||
curl http://localhost:4820/api/sessions/sess_abc123/notifications
|
||
```
|
||
|
||
**Example Response:**
|
||
|
||
```json
|
||
{
|
||
"notifications": [
|
||
{
|
||
"id": 1,
|
||
"session_id": "sess_abc123",
|
||
"notification_type": "backgroundTaskComplete",
|
||
"message": "Explore agent completed",
|
||
"created_at": "2024-03-18T12:05:00Z"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
### Remote Data Sources
|
||
|
||
The `/api/remote-sources/*` namespace configures **remote SSH machines** the dashboard pulls Claude Code history from, so one dashboard can consolidate sessions from several machines. **No secrets are stored** — SSH authentication defers entirely to the host's SSH stack (ssh-agent, `~/.ssh/config`, key files). Every imported session is tagged with the source's id in the `sessions.source` column (the built-in local history uses the id `local`), which powers the `sources` filter below.
|
||
|
||
**RemoteSource shape:**
|
||
|
||
```json
|
||
{
|
||
"id": "4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11",
|
||
"label": "Work laptop",
|
||
"host": "son@studio.local",
|
||
"ssh_port": 22,
|
||
"identity_file": "~/.ssh/id_ed25519",
|
||
"remote_home": "~/.claude",
|
||
"enabled": true,
|
||
"status": "ok",
|
||
"last_error": null,
|
||
"last_sync_at": "2026-07-22T18:41:55.117Z",
|
||
"last_sync_counts": {
|
||
"imported": 9,
|
||
"skipped": 41,
|
||
"backfilled": 0,
|
||
"errors": 0,
|
||
"sessions_seen": 50,
|
||
"sessions_tagged": 50
|
||
},
|
||
"created_at": "2026-07-20T09:15:00.000Z",
|
||
"updated_at": "2026-07-22T18:41:55.117Z"
|
||
}
|
||
```
|
||
|
||
`ssh_port`, `identity_file`, `remote_home`, `last_error`, `last_sync_at`, and `last_sync_counts` are nullable. `status` is one of `idle`, `syncing`, `ok`, `error`.
|
||
|
||
#### List Remote Sources
|
||
|
||
```http
|
||
GET /api/remote-sources
|
||
```
|
||
|
||
Returns all configured remote sources. Response: `{ "sources": RemoteSource[] }`.
|
||
|
||
#### Create Remote Source
|
||
|
||
```http
|
||
POST /api/remote-sources
|
||
```
|
||
|
||
**Request Body:**
|
||
|
||
| Field | Type | Required | Description |
|
||
|-------|------|----------|-------------|
|
||
| `label` | string | Yes | Human-readable name |
|
||
| `host` | string | Yes | SSH destination (`user@host`) or a `~/.ssh/config` alias |
|
||
| `ssh_port` | integer | No | SSH port (defers to SSH default / config when omitted) |
|
||
| `identity_file` | string | No | Private-key path passed to ssh (`-i`) |
|
||
| `remote_home` | string | No | Remote Claude home (defaults to remote `~/.claude`) |
|
||
|
||
> **Cursor (informational):** Sessions imported from `~/.claude` include **Cursor** agent usage on that machine too — Cursor happens to use the same paths as Claude Code. 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
|
||
|
||
```http
|
||
PATCH /api/remote-sources/:id
|
||
```
|
||
|
||
Partial update — only the keys present in the body change. Same fields (and the same validation codes) as create; both `label` and `host` are optional here. Returns `{ "source": RemoteSource }`, or **404** if the id is unknown.
|
||
|
||
#### Delete Remote Source
|
||
|
||
```http
|
||
DELETE /api/remote-sources/:id
|
||
```
|
||
|
||
**Query Parameters:**
|
||
|
||
| Parameter | Type | Default | Description |
|
||
|-----------|------|---------|-------------|
|
||
| `purge` | boolean | `false` | When `true`, also delete this source's imported sessions. When omitted/`false`, those sessions are **detached** — reassigned to the `local` source so history is preserved |
|
||
|
||
Returns `{ "ok": true, "purged": <bool> }` (`purged` is `true` only when `?purge=true` deleted the sessions). **404** if the id is unknown.
|
||
|
||
#### Test Remote Source
|
||
|
||
```http
|
||
POST /api/remote-sources/:id/test
|
||
```
|
||
|
||
Runs an SSH connectivity probe. Returns `{ "ok": <bool>, "message": <string>, "remoteProjects?": string[] }` — `remoteProjects` lists the discovered remote project directories on success. Does not import anything. **404** if the id is unknown.
|
||
|
||
#### Sync Remote Source
|
||
|
||
```http
|
||
POST /api/remote-sources/:id/sync
|
||
```
|
||
|
||
Pulls Claude Code history from the remote over SSH now, through the same idempotent import pipeline used locally, tagging imported sessions with this source's id. Progress/completion is also broadcast over the WebSocket as [`remote_source.status`](#remote_sourcestatus) frames.
|
||
|
||
**Example Response:**
|
||
|
||
```json
|
||
{
|
||
"ok": true,
|
||
"imported": 9,
|
||
"skipped": 41,
|
||
"backfilled": 0,
|
||
"errors": 0,
|
||
"sessions_seen": 50,
|
||
"sessions_tagged": 50
|
||
}
|
||
```
|
||
|
||
**404** if the id is unknown; **500** with `{ error: { code: "SYNC_FAILED", message } }` on SSH/import failure.
|
||
|
||
#### Sync All Remote Sources
|
||
|
||
```http
|
||
POST /api/remote-sources/sync-all
|
||
```
|
||
|
||
Pulls history from **every enabled** source sequentially (one SSH connection at a time). Per-source failures are isolated — one unreachable machine never aborts the others — and each outcome is returned in `results`. Always **200**.
|
||
|
||
**Example Response:**
|
||
|
||
```json
|
||
{ "ok": true, "synced": 2, "results": [{ "id": "src_a", "ok": true }, { "id": "src_b", "ok": false, "error": "ssh exited with code 255" }] }
|
||
```
|
||
|
||
#### The `sources` filter
|
||
|
||
`GET /api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, and `/api/analytics` accept an optional `sources` query parameter: a comma-separated list of source ids to include (omit for all). `GET /api/sessions/facets` correspondingly returns a `sources: string[]` array (alongside `cwds`) listing the distinct `sessions.source` values so the UI can build the filter dropdown.
|
||
|
||
```bash
|
||
curl "http://localhost:4820/api/sessions?sources=local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11"
|
||
```
|
||
|
||
---
|
||
|
||
### Claude Config Explorer
|
||
|
||
The `/api/cc-config/*` namespace powers the Claude Config Explorer, now the **Claude Code** tab of Settings (`/settings?tool=claude-code`). All read endpoints are pure file reads under `CLAUDE_HOME` and the project's `.claude/` dir. File mutations cover the low-risk text-file artifacts (skills, subagents, slash commands, output styles, memory), `keybindings.json`, and `settings.json` layers **via a per-key merge only** — each always creates a timestamped backup before writing. Plugin and marketplace management is delegated to the `claude plugin …` CLI. MCP server config stays read-only, because it is written concurrently by the running Claude Code CLI.
|
||
|
||
```http
|
||
GET /api/cc-config/overview
|
||
GET /api/cc-config/skills?scope=user|project|all
|
||
GET /api/cc-config/agents
|
||
GET /api/cc-config/commands
|
||
GET /api/cc-config/output-styles
|
||
GET /api/cc-config/plugins
|
||
GET /api/cc-config/marketplaces
|
||
GET /api/cc-config/mcp
|
||
GET /api/cc-config/hooks
|
||
GET /api/cc-config/hook-scripts
|
||
GET /api/cc-config/keybindings
|
||
PUT /api/cc-config/keybindings Body: { groups: [{ context, bindings: [{ key, action }] }] }
|
||
GET /api/cc-config/statusline
|
||
GET /api/cc-config/settings
|
||
GET /api/cc-config/memory
|
||
GET /api/cc-config/file?path=<absolute-path>
|
||
GET /api/cc-config/backups[?scope=&type=]
|
||
GET /api/cc-config/profiles
|
||
POST /api/cc-config/profiles Body: { name }
|
||
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`, `savedAt`, `active`, `accountUuid`, `email`, `displayName`, `organization`, `seatTier`, `subscriptionType`, `expiresAt`). `name` must match `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$` (it is the filename).
|
||
|
||
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` (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), `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 active profile). 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.
|
||
|
||
### Run Claude via tmux+PTY
|
||
|
||
The `/api/run/*` namespace starts and manages `claude` subprocesses in tmux-backed pseudoterminals, one session per lane. The Workspace page renders an interactive xterm.js terminal attached to the session over `/ws-pty/:runId` (binary PTY frames, not JSON). Every route enforces a same-origin / loopback-Origin guard; browser requests must come from `localhost`, `127.0.0.1`, `::1`, or `0.0.0.0`. CLI / curl requests with no `Origin` header pass through. When `DASHBOARD_TOKEN` is set, a valid token is also required here (like the rest of `/api/*` — see [Authentication](#authentication)). **`tmux` must be installed on the dashboard server** (same operational requirement as better-sqlite3's native module).
|
||
|
||
```http
|
||
GET /api/run List all live runs (computed fresh from tmux state)
|
||
GET /api/run/tmux { available: bool } — whether tmux is on PATH
|
||
GET /api/run/binary?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
|
||
|
||
```javascript
|
||
const ws = new WebSocket('ws://localhost:4820/ws');
|
||
|
||
ws.onopen = () => {
|
||
console.log('Connected to Agent Dashboard');
|
||
};
|
||
|
||
ws.onmessage = (event) => {
|
||
const message = JSON.parse(event.data);
|
||
console.log('Received:', message);
|
||
};
|
||
|
||
ws.onerror = (error) => {
|
||
console.error('WebSocket error:', error);
|
||
};
|
||
|
||
ws.onclose = () => {
|
||
console.log('Disconnected');
|
||
};
|
||
```
|
||
|
||
When `DASHBOARD_TOKEN` is configured, pass the token as `?token=<token>` on the `/ws` upgrade (an `x-dashboard-token` header also works):
|
||
|
||
```javascript
|
||
const ws = new WebSocket('ws://localhost:4820/ws?token=YOUR_DASHBOARD_TOKEN');
|
||
```
|
||
|
||
### WebSocket Lifecycle
|
||
|
||
```mermaid
|
||
stateDiagram-v2
|
||
[*] --> Connecting: new WebSocket()
|
||
Connecting --> Connected: onopen
|
||
Connecting --> Disconnected: onerror
|
||
|
||
Connected --> Connected: onmessage
|
||
Connected --> Disconnected: onclose
|
||
Connected --> Disconnected: onerror
|
||
|
||
Disconnected --> Connecting: Reconnect
|
||
Disconnected --> [*]
|
||
|
||
note right of Connected
|
||
Heartbeat: ping every 30s
|
||
Broadcast: Real-time events
|
||
end note
|
||
```
|
||
|
||
### Event Types
|
||
|
||
Server broadcasts JSON messages to all connected clients:
|
||
|
||
#### session.created
|
||
|
||
Sent when a new session is created.
|
||
|
||
```json
|
||
{
|
||
"type": "session.created",
|
||
"data": {
|
||
"id": 1,
|
||
"session_id": "sess_abc123",
|
||
"model": "claude-sonnet-4",
|
||
"status": "active",
|
||
"total_cost": 0,
|
||
"created_at": "2024-03-18T12:00:00Z",
|
||
"updated_at": "2024-03-18T12:00:00Z"
|
||
}
|
||
}
|
||
```
|
||
|
||
#### session.updated
|
||
|
||
Sent when session data changes (status, cost, etc.).
|
||
|
||
```json
|
||
{
|
||
"type": "session.updated",
|
||
"data": {
|
||
"id": 1,
|
||
"session_id": "sess_abc123",
|
||
"model": "claude-sonnet-4",
|
||
"status": "completed",
|
||
"total_cost": 1.23,
|
||
"created_at": "2024-03-18T12:00:00Z",
|
||
"updated_at": "2024-03-18T14:30:00Z"
|
||
}
|
||
}
|
||
```
|
||
|
||
#### agent.created
|
||
|
||
Sent when a new agent starts.
|
||
|
||
```json
|
||
{
|
||
"type": "agent.created",
|
||
"data": {
|
||
"id": 1,
|
||
"agent_id": "agent_xyz789",
|
||
"session_id": "sess_abc123",
|
||
"agent_type": "explore",
|
||
"status": "running",
|
||
"current_tool": null,
|
||
"input_tokens": 0,
|
||
"output_tokens": 0,
|
||
"cost": 0,
|
||
"created_at": "2024-03-18T12:00:00Z",
|
||
"updated_at": "2024-03-18T12:00:00Z"
|
||
}
|
||
}
|
||
```
|
||
|
||
#### agent.updated
|
||
|
||
Sent when agent data changes (tokens, status, current_tool).
|
||
|
||
```json
|
||
{
|
||
"type": "agent.updated",
|
||
"data": {
|
||
"id": 1,
|
||
"agent_id": "agent_xyz789",
|
||
"session_id": "sess_abc123",
|
||
"agent_type": "explore",
|
||
"status": "completed",
|
||
"current_tool": null,
|
||
"input_tokens": 1500,
|
||
"output_tokens": 800,
|
||
"cost": 0.45,
|
||
"created_at": "2024-03-18T12:00:00Z",
|
||
"updated_at": "2024-03-18T12:05:00Z"
|
||
}
|
||
}
|
||
```
|
||
|
||
#### tool.executed
|
||
|
||
Sent when a tool execution completes.
|
||
|
||
```json
|
||
{
|
||
"type": "tool.executed",
|
||
"data": {
|
||
"id": 1,
|
||
"agent_id": "agent_xyz789",
|
||
"tool_name": "bash",
|
||
"duration_ms": 1234,
|
||
"success": 1,
|
||
"error_message": null,
|
||
"created_at": "2024-03-18T12:01:00Z"
|
||
}
|
||
}
|
||
```
|
||
|
||
#### notification.received
|
||
|
||
Sent when a notification is created.
|
||
|
||
```json
|
||
{
|
||
"type": "notification.received",
|
||
"data": {
|
||
"id": 1,
|
||
"session_id": "sess_abc123",
|
||
"notification_type": "backgroundTaskComplete",
|
||
"message": "Explore agent completed",
|
||
"created_at": "2024-03-18T12:05:00Z"
|
||
}
|
||
}
|
||
```
|
||
|
||
#### /ws-pty/:runId — PTY frames
|
||
|
||
A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on lane load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `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.
|
||
|
||
```json
|
||
{ "type": "cc_config_changed", "data": { "source": "dashboard", "action": "write", "scope": "user", "type": "skill", "name": "my-skill" } }
|
||
{ "type": "cc_config_changed", "data": { "source": "fs", "paths": ["/Users/foo/.claude/settings.json"] } }
|
||
```
|
||
|
||
#### remote_data.updated
|
||
|
||
Broadcast once per successful remote sync (background poller, manual **Sync now**, or immediate pull after add/re-enable). Clients use this — and the per-session `session_created` / `session_updated` frames emitted in the same pass — to refetch sessions, costs, and analytics without polling.
|
||
|
||
```json
|
||
{
|
||
"type": "remote_data.updated",
|
||
"data": {
|
||
"sourceId": "src_a1b2c3",
|
||
"source": "src_a1b2c3",
|
||
"label": "dev-box",
|
||
"counters": { "imported": 1, "skipped": 0, "sessions_tagged": 3 },
|
||
"last_sync_at": "2026-07-26T21:15:00.000Z"
|
||
}
|
||
}
|
||
```
|
||
|
||
#### remote_source.status
|
||
|
||
Broadcast when a remote data source changes sync state (during/after `POST /api/remote-sources/:id/sync`) or is deleted. `status` is one of `idle`, `syncing`, `ok`, `error`, or `deleted`. `error` and `last_sync_at` are optional and present when relevant. See [Remote Data Sources](#remote-data-sources).
|
||
|
||
```json
|
||
{ "type": "remote_source.status", "data": { "id": "4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11", "status": "syncing" } }
|
||
{ "type": "remote_source.status", "data": { "id": "4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11", "status": "ok", "last_sync_at": "2026-07-22T18:41:55.117Z" } }
|
||
{ "type": "remote_source.status", "data": { "id": "4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11", "status": "error", "error": "ssh exited with code 255" } }
|
||
{ "type": "remote_source.status", "data": { "id": "4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11", "status": "deleted" } }
|
||
```
|
||
|
||
### Event Flow
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant Hook as Hook Handler
|
||
participant Server as Express Server
|
||
participant DB as SQLite
|
||
participant WS as WebSocket Server
|
||
participant Client1 as Client 1
|
||
participant Client2 as Client 2
|
||
|
||
Hook->>Server: POST /hooks/post-tool-use
|
||
Server->>DB: Create tool_execution
|
||
DB-->>Server: Inserted row
|
||
Server->>WS: broadcast({ type: 'tool.executed', data })
|
||
|
||
par Broadcast to all clients
|
||
WS->>Client1: { type: 'tool.executed', ... }
|
||
WS->>Client2: { type: 'tool.executed', ... }
|
||
end
|
||
|
||
Server-->>Hook: 200 OK
|
||
```
|
||
|
||
---
|
||
|
||
## Error Handling
|
||
|
||
### Error Response Format
|
||
|
||
All error responses follow this structure:
|
||
|
||
```json
|
||
{
|
||
"error": "Human-readable error message",
|
||
"code": "ERROR_CODE",
|
||
"details": {
|
||
"field": "Additional context"
|
||
}
|
||
}
|
||
```
|
||
|
||
### HTTP Status Codes
|
||
|
||
| Code | Meaning | Example |
|
||
|------|---------|---------|
|
||
| 200 | Success | Resource retrieved |
|
||
| 201 | Created | Resource created |
|
||
| 400 | Bad Request | Invalid JSON, missing fields |
|
||
| 404 | Not Found | Session/agent not found |
|
||
| 409 | Conflict | Duplicate pattern |
|
||
| 500 | Server Error | Database error |
|
||
|
||
### Error Examples
|
||
|
||
**400 Bad Request:**
|
||
|
||
```json
|
||
{
|
||
"error": "Missing required field: pattern",
|
||
"code": "VALIDATION_ERROR",
|
||
"details": {
|
||
"field": "pattern",
|
||
"message": "Pattern is required"
|
||
}
|
||
}
|
||
```
|
||
|
||
**404 Not Found:**
|
||
|
||
```json
|
||
{
|
||
"error": "Session not found",
|
||
"code": "NOT_FOUND",
|
||
"details": {
|
||
"session_id": "sess_invalid"
|
||
}
|
||
}
|
||
```
|
||
|
||
**409 Conflict:**
|
||
|
||
```json
|
||
{
|
||
"error": "Pricing rule already exists",
|
||
"code": "DUPLICATE_PATTERN",
|
||
"details": {
|
||
"pattern": "claude-sonnet-4"
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Rate Limiting
|
||
|
||
Currently, no rate limiting is enforced. For production deployments, implement rate limiting:
|
||
|
||
```javascript
|
||
// Using express-rate-limit
|
||
import rateLimit from 'express-rate-limit';
|
||
|
||
const limiter = rateLimit({
|
||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||
max: 100, // Limit each IP to 100 requests per windowMs
|
||
message: 'Too many requests, please try again later.'
|
||
});
|
||
|
||
app.use('/api/', limiter);
|
||
```
|
||
|
||
---
|
||
|
||
## Pagination
|
||
|
||
For endpoints returning lists, use `limit` and `offset`:
|
||
|
||
```http
|
||
GET /api/sessions?limit=20&offset=40
|
||
```
|
||
|
||
**Pagination Pattern:**
|
||
|
||
```mermaid
|
||
graph LR
|
||
Page1[Page 1<br/>offset=0<br/>limit=20] --> Page2[Page 2<br/>offset=20<br/>limit=20]
|
||
Page2 --> Page3[Page 3<br/>offset=40<br/>limit=20]
|
||
Page3 --> PageN[Page N<br/>offset=N*20<br/>limit=20]
|
||
|
||
style Page1 fill:#3B82F6
|
||
```
|
||
|
||
**Response includes pagination metadata:**
|
||
|
||
```json
|
||
{
|
||
"sessions": [...],
|
||
"total": 156,
|
||
"limit": 20,
|
||
"offset": 40,
|
||
"has_more": true
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Examples
|
||
|
||
### Full Session Workflow
|
||
|
||
```javascript
|
||
// 1. List sessions
|
||
const sessions = await fetch('http://localhost:4820/api/sessions');
|
||
const { sessions: sessionList } = await sessions.json();
|
||
|
||
// 2. Get specific session
|
||
const sessionId = sessionList[0].session_id;
|
||
const session = await fetch(`http://localhost:4820/api/sessions/${sessionId}`);
|
||
const sessionData = await session.json();
|
||
|
||
// 3. Get session agents
|
||
const agents = await fetch(`http://localhost:4820/api/sessions/${sessionId}/agents`);
|
||
const { agents: agentList } = await agents.json();
|
||
|
||
// 4. Get agent tools
|
||
const agentId = agentList[0].agent_id;
|
||
const tools = await fetch(`http://localhost:4820/api/agents/${agentId}/tools`);
|
||
const { tools: toolList } = await tools.json();
|
||
|
||
console.log('Session:', sessionData);
|
||
console.log('Agents:', agentList);
|
||
console.log('Tools:', toolList);
|
||
```
|
||
|
||
### Real-time Monitoring
|
||
|
||
```javascript
|
||
// Connect to WebSocket
|
||
const ws = new WebSocket('ws://localhost:4820/ws');
|
||
|
||
ws.onopen = () => {
|
||
console.log('Connected to real-time stream');
|
||
};
|
||
|
||
ws.onmessage = (event) => {
|
||
const message = JSON.parse(event.data);
|
||
|
||
switch (message.type) {
|
||
case 'session.created':
|
||
console.log('New session:', message.data.session_id);
|
||
break;
|
||
|
||
case 'agent.updated':
|
||
console.log('Agent updated:', message.data.agent_id);
|
||
console.log('Cost:', message.data.cost);
|
||
break;
|
||
|
||
case 'tool.executed':
|
||
console.log('Tool executed:', message.data.tool_name);
|
||
console.log('Duration:', message.data.duration_ms, 'ms');
|
||
break;
|
||
}
|
||
};
|
||
```
|
||
|
||
### Creating Pricing Rules
|
||
|
||
```javascript
|
||
// Create custom rule
|
||
const response = await fetch('http://localhost:4820/api/pricing', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
pattern: 'my-custom-model',
|
||
input_cost_per_1m: 5.0,
|
||
output_cost_per_1m: 20.0
|
||
})
|
||
});
|
||
|
||
const { rule } = await response.json();
|
||
console.log('Created rule:', rule);
|
||
|
||
// List all rules
|
||
const rules = await fetch('http://localhost:4820/api/pricing');
|
||
const { rules: ruleList } = await rules.json();
|
||
console.log('All rules:', ruleList);
|
||
|
||
// Delete rule
|
||
await fetch('http://localhost:4820/api/pricing/my-custom-model', {
|
||
method: 'DELETE'
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## Summary
|
||
|
||
The Agent Dashboard API provides:
|
||
|
||
- ✅ **RESTful endpoints** for querying sessions, agents, tools, pricing
|
||
- ✅ **WebSocket streaming** for real-time updates
|
||
- ✅ **Type-safe responses** with consistent JSON structure
|
||
- ✅ **Error handling** with descriptive error codes
|
||
- ✅ **Pagination** for large datasets
|
||
- ✅ **Pricing management** with custom rule support
|
||
|
||
For interactive API exploration with live request/response examples, see the built-in Swagger UI at `/api/docs` and ReDoc at `/api/redoc`. For MCP integration, see [MCP.md](./MCP.md).
|