5bd1c0323f
The lane strip's tiles are now drag-reorderable (Alt+←/→ is the keyboard
path), and a lane's name can be set in place from the detail header. The
"Lane 23 ·" prefix is gone from both — the bare `#23` stays on the tile,
because that number is how the CLI addresses a lane.
Card order is a per-viewer preference, not a lane fact: it lives in
localStorage (`agenthub.workspace.laneOrder`) beside the split-view layout
and writes nothing server-side, so `GET /api/lanes` and `agenthub lanes`
keep answering in id order. Renaming is the opposite — it is PATCH
/api/lanes/:id with {title}, the one lane column a user now types into
directly.
That made a patch value reachable by hand for the first time, so
`bindable` now rejects any non-scalar for every PATCHABLE column with
EBADFIELD, and the route answers 400 for that and for the
SQLITE_CONSTRAINT family, rather than handing better-sqlite3 a datatype
throw and reporting 500 on plain bad input.
The uncommitted-files tooltip became a real panel. `GET /api/lanes/:id/git`
now returns `files` as {path, status, added, deleted} records — deltas from
one `git diff --numstat HEAD`, null where git has none to report (untracked,
binary, renamed) — and the card renders them portalled to the body, so the
Workspace's own scroll box cannot clip the list.
2279 lines
99 KiB
Markdown
2279 lines
99 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`.
|
||
|
||
Every patch value must be a **scalar** — a string, a number or `null`. An
|
||
object, an array or a boolean for any column but `links` (which is stored as
|
||
JSON and must be an object) is rejected with `400 EBADFIELD`, as is a `NOT
|
||
NULL` column patched to `null`: such a value reaches SQLite as a datatype or
|
||
constraint error, which Express could only report as a `500` on what is plainly
|
||
the caller's mistake.
|
||
|
||
`title` is patchable, which is how the dashboard's inline rename sets a lane's
|
||
display name. It is trimmed and capped at 200 characters, and `null` clears it
|
||
rather than erroring — clearing a title is legitimate, since callers fall back
|
||
to showing the lane's `cwd`. Renaming is display-only: a lane's branch name was
|
||
slugified from its title once, at creation.
|
||
|
||
`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 `503 ETMUXMISSING` is returned when the host has no `tmux` binary. Every run the dashboard starts is a tmux session, so this is a missing dependency rather than a bad request — the message carries the install command, and the same code comes back from `POST /api/run`. Nothing else in the dashboard is affected.
|
||
|
||
Error `409 ERUNLIVE` is returned if the lane already has a live run; stop it first with the `stop` action. The same code and status come back when the lane's tmux pane is already running an agent while a `prompt` or `resumeSessionId` was sent — that argv cannot be typed over a live agent, so the request is refused instead of answered 200 with nothing done. A start that asks for neither still adopts the existing pane silently.
|
||
|
||
#### 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 working copy
|
||
|
||
```http
|
||
GET /api/lanes/:id/git
|
||
```
|
||
|
||
What the lane's directory looks like to git right now:
|
||
|
||
```json
|
||
{
|
||
"available": true,
|
||
"branch": "feat/rename-metric",
|
||
"head": "9b3e74a",
|
||
"subject": "free-text rule mode in the form",
|
||
"dirty": 2,
|
||
"untracked": 1,
|
||
"files": [
|
||
{ "path": "server/lib/alerts.js", "status": " M", "added": 12, "deleted": 3 },
|
||
{ "path": "scratch.txt", "status": "??", "added": null, "deleted": null }
|
||
]
|
||
}
|
||
```
|
||
|
||
- A `cwd` that is missing, is not a git repository, or makes git fail returns
|
||
`{"available": false}` with HTTP **200** — a normal state, not a fault.
|
||
- `files` names the files the counts came from, **capped at 20** (the counts
|
||
themselves are never capped). `status` is the two-character
|
||
`git status --porcelain` code; `path` is the new path for a rename.
|
||
- `added`/`deleted` come from one `git diff --numstat HEAD` — staged and
|
||
unstaged work together — and are `null` when git has no delta to report: an
|
||
untracked file, a binary file, a rename, or a repo with no commits yet.
|
||
- Not folded into `GET /api/lanes` on purpose: it shells out to git five times,
|
||
and the lane list is polled and re-broadcast on every hook. See
|
||
`docs/LANES.md` for the full treatment.
|
||
|
||
#### 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 recorded
|
||
run and grouped by pipeline template, busiest first.
|
||
|
||
Read from `lane_stage_events`, the append-only log every stage declaration
|
||
writes — **not** from `lanes.stages`. That blob describes the pass in flight: it
|
||
is overwritten when a node is re-entered and truncated when the lane moves
|
||
backwards, so reading it counted one run per lane row no matter how many
|
||
features that lane shipped, and no amount of use ever moved the numbers. A run
|
||
is one pass through the template: the log starts a new one when the lane moves
|
||
backwards or leaves the template's last node.
|
||
|
||
Templates configured on a lane are always listed, at zero, even before that lane
|
||
records anything — a pipeline going unused has to be visible as such.
|
||
|
||
**Query parameters:**
|
||
- **`lane`** — lane id; narrows the aggregation to that lane's own runs. 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, "detected": 0 }
|
||
]
|
||
}
|
||
],
|
||
"lanes": [{ "id": 1, "title": "api lane", "cwd": "/w/api" }]
|
||
}
|
||
```
|
||
|
||
- **`detected`** — runs that reached the node by inference from the hook stream
|
||
with no declaration. Counted apart from `entered`: it says the work happened,
|
||
not that the gate was met. A node the hooks saw is reported as such rather
|
||
than as "never reached", which is what a session that never runs the pipeline
|
||
commands used to look like.
|
||
- **`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/marketplace management and MCP server add/remove are delegated to the `claude plugin …` / `claude mcp …` CLI, because `~/.claude.json` and the plugin manifests are rewritten 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? }
|
||
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? }
|
||
POST /api/cc-config/project-memory Body: { dir, content }
|
||
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, reload? }
|
||
GET /api/cc-config/plugins/update-all
|
||
POST /api/cc-config/plugins/update-all Body: { reload? }
|
||
POST /api/cc-config/reload Body: { kinds?: ["plugins"|"skills"] }
|
||
POST /api/cc-config/marketplaces/add Body: { source, scope? }
|
||
POST /api/cc-config/marketplaces/action Body: { name, action: update|remove }
|
||
POST /api/cc-config/mcp/add Body: { name, scope, transport?, url?, headers?, command?, args?, env? }
|
||
POST /api/cc-config/mcp/remove Body: { name, scope? }
|
||
```
|
||
|
||
`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.
|
||
|
||
### Updating everything, and reloading sessions
|
||
|
||
`POST /api/cc-config/plugins/update-all` refreshes every marketplace, then updates every installed plugin, then tells the open Claude Code sessions to pick the changes up. It **returns as soon as a detached worker is spawned** — the response is an acknowledgement, not a result — and progress is polled from `GET` on the same path, which reads the worker's journal at `~/.claude/agenthub/runtime/cc-update-all.json`.
|
||
|
||
That indirection is structural, not stylistic: the plugin this dashboard *itself* runs from is updated **last**, followed by `plugin-bootstrap.js --force`, which stops this server and starts the replacement. A request handler doing the work would die partway through with the remaining plugins unrun and nothing able to report why, so the journal lives outside the plugin cache the update replaces and whichever server is alive serves it. Plugin ids come from `claude plugin list --json`, never from the request body, and the "self" plugin is derived from the server's own path — no caller can nominate a different plugin for the restart. A second concurrent batch is refused with 409 `EBUSY`.
|
||
|
||
Batch `status` is `running`, `done`, `partial` (finished, at least one step failed), `failed`, or `abandoned`. `abandoned` is **derived, not stored**: the worker is detached, so a reboot leaves a `running` journal with nobody to finish it, and after an hour it is reported as abandoned so the button is usable again.
|
||
|
||
`POST /api/cc-config/reload` is the reload step on its own. It types `/reload-plugins` and `/reload-skills` into the dashboard's live lane panes via `tmux send-keys`. **Both commands are scoped by Claude Code to the session that runs them** (`/reload-plugins` activates pending plugin changes "in the current session"; `/reload-skills` picks up skills changed on disk "during this session"), so nothing can reload a session from the outside — typing into a session that is already running is the only real mechanism, and it is what this does. It never spawns a `claude` process: a one-shot `claude -p "/reload-plugins"` would reload its own short-lived session, exit, and leave a junk session behind. Panes sitting at a shell prompt are skipped, since the text would run as a shell command instead; only `claude` lanes are addressed, as Codex has no equivalent. `lanes_total: 0` is a normal outcome, not an error. Sessions running outside the dashboard cannot be reached and must run the command themselves.
|
||
|
||
`POST /api/cc-config/plugins/action` accepts `reload: true` for the counterpart behaviour: update one plugin, reload immediately. The batch reloads once at the end instead — one reload picks up every pending change, where N of them would re-scan the same directory N times. A reload is never sent after a *failed* action.
|
||
|
||
`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`).
|
||
|
||
**`POST /api/{cc,codex}-config/project-memory` (a new project's memory file).** Creates `CLAUDE.md` (Claude) or `AGENTS.md` (Codex) inside the directory named by `dir` — the one write path that honours a caller-supplied directory, because bootstrapping a project the dashboard has never run in is impossible from a writer pinned to the server's own cwd (which is what `PUT /file`'s `"project"` scope means). It stays narrow: `dir` must be absolute and already exist (nothing is created for you), the filename comes from the route and is never taken from the body, and an existing file — including a symlink under that name — returns **409 `EEXISTS`** rather than being overwritten, so this endpoint can only ever create. Bad or missing directories return 400 `EBADDIR`. Unlike the other config routes it sits behind the same loopback-`Origin` guard `/api/run` uses (403 `EBADORIGIN`): a page in the user's browser could otherwise plant agent instructions in a project directory. Returns `{ file, size, created: true }`, where `file` is the realpath'd target, and broadcasts `cc_config_changed`.
|
||
|
||
**`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); mutating calls are serialized in-process. `CLAUDE_CONFIG_DIR` is pinned to `CLAUDE_HOME` only when the operator explicitly set `CLAUDE_HOME` — left unset (the default), the CLI's own default config dir already matches what `cc-discovery` reads, so the pin is left off rather than pointed at the wrong directory (see `runClaude()`'s comment in `cc-plugin-cli.js` for the incident this fixed). `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 }`.
|
||
|
||
**MCP servers.** `POST /api/cc-config/mcp/add` and `POST /api/cc-config/mcp/remove` shell out to `claude mcp add`/`claude mcp remove` the same way the plugin routes shell out to `claude plugin …` — an argv array, never a shell string, serialized on the SAME in-process queue `cc-plugin-cli.js` exports (`serialize`), so a plugin install and an MCP add can never spawn `claude` concurrently. `scope` is `user` or `local` only (required on add, optional on remove — omitted, the CLI removes from whichever scope the server exists in). This is the inverse of what it sounds like: `user` writes `~/.claude.json`'s top level, `local` writes its `projects[<root>]` block — the exact source `GET /mcp`'s `projectScoped` bucket reads. The CLI's own `project` scope (a separate, shared `.mcp.json`) is refused with 400: `readMcpServers` never reads that file, so a server added there would report success and never appear in the list. `add` takes either `url` (+ optional `transport: "http"|"sse"`, `headers`) for a remote server, or `command` + `args` (+ optional `env`) for a stdio server — exactly one of the two forms. Validation failures return 400 `EBADARG`; a non-zero CLI exit returns 422 `ECLIEXIT`. `GET /api/cc-config/mcp` is unaffected — it still reads `~/.claude.json` directly; the client refetches it after either call succeeds. `GET /mcp`'s server objects carry `removable: false` for rows sourced from `~/.claude/settings.json` (absent/true means removable) — no CLI scope addresses them, so the panel reads this flag rather than matching the `source` display string to decide whether to offer Remove.
|
||
|
||
`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.
|
||
|
||
```http
|
||
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? }
|
||
POST /api/codex-config/project-memory Body: { dir, content }
|
||
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](#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`.
|
||
|
||
---
|
||
|
||
### AI Router profiles
|
||
|
||
```
|
||
GET /api/ai-routers Every saved profile, the built-in `native` one first
|
||
POST /api/ai-routers Create — Body: { label, baseUrl, apiKey?, model, models? }
|
||
POST /api/ai-routers/test Probe an endpoint — Body: { baseUrl, model, apiKey?, name? }
|
||
PATCH /api/ai-routers/:name Update the same fields, all optional
|
||
DELETE /api/ai-routers/:name Delete one saved profile
|
||
```
|
||
|
||
`PATCH` and `DELETE` also answer with `lanes: { updated, failed[] }` — see "an edit reaches the lanes" below.
|
||
|
||
A **router profile** is an endpoint that stands in front of Claude Code or Codex (9Router, OpenRouter, a self-hosted gateway) plus the model names to use against it. It powers the **AI Router** tab of Settings (`/settings?tool=ai-router`), which stores profiles and attaches nothing — the tab's **Test** button (`POST /api/ai-routers/test`, below) is its only outbound call. Attaching a profile to a lane happens in the "+ Add lane" modal, or later from the lane's card in Workspace.
|
||
|
||
`models` carries the five per-tier names (`opus`, `sonnet`, `haiku`, `smallFast`, `subagent`). Any tier omitted is filled from `model`, so a router that answers to one symbolic name needs one field, not six.
|
||
|
||
**No response here ever contains key material.** A saved profile reports `hasKey: true|false` and there is deliberately no `GET /:name` — a route returning one profile in full would be the single way an API key could leave the server. Profiles live one JSON file each under `<CLAUDE_HOME>/agenthub-routers/` (dir `0700`, files `0600`). An omitted `apiKey` on `PATCH` keeps the stored one; the edit form cannot display a key, so it has none to send back.
|
||
|
||
**`POST /api/ai-routers/test`** answers the question saving cannot: does this endpoint, with this key, actually serve this model? It sends one Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages` (a 15 s timeout), which is the wire shape a claude lane's eight env keys point at — a Codex-only router is not covered. A router that answers is a `200` here whatever it replies: `{ ok, status, message }` carries the router's own words (truncated to 300 characters), and `status` is `null` when nothing answered at all. Only bad input is a 4xx.
|
||
|
||
The stored key is only ever sent to the endpoint it was stored against. A blank `apiKey` falls back to the key of the profile named by `name`, but **only** while the submitted `baseUrl` still equals the saved one — so testing a retyped endpoint needs a retyped key (`400 EBADKEY`). Without that rule this route would be a way to make the server POST a saved API key to any URL a browser names.
|
||
|
||
`native` is a built-in profile, not the absence of one: empty `baseUrl`/`apiKey` and the vendor's own model names, so switching back is the same write path as switching to a router. It answers `409 EBUILTIN` to any update, delete, or attempt to shadow its name. Other errors: `400 EBADLABEL` / `EBADURL` (an endpoint must be `http:` or `https:`) / `EBADMODEL`, `404 ENOTFOUND`.
|
||
|
||
**`POST /api/lanes/:id/router/apply`** — Body: `{ profile: "<name>" }` — is where a profile reaches disk. Only the name comes from the request; what is written, and where, is derived from the lane row:
|
||
|
||
- A **claude** lane gets eight env keys merged into `<lane.cwd>/.claude/settings.local.json` (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `CLAUDE_CODE_SUBAGENT_MODEL`). Every other key in that file, including every other env var, is preserved; a file that is not valid JSON is reported as `409 EBADJSON` and left untouched. `.local.json` rather than `settings.json` because the written value includes an API key.
|
||
- A **codex** lane gets `$CODEX_HOME/<name>.config.toml`, the layer `codex -p <name>` loads on top of the base config — Codex has no project-level config, so the base `config.toml` is never touched. The layer sets `wire_api = "responses"`; `"chat"` is not an option, codex-cli 0.149 refuses to load a config containing it. `env_key` is written only when the profile has a key — naming a variable that is never set is itself a startup error — and the key stays out of the file: the lane's spawn hands `AGENTHUB_ROUTER_KEY` to the pane through tmux's own `-e`, never as typed text.
|
||
|
||
Attaching to a **native** lane is not a no-op for claude: `ANTHROPIC_BASE_URL` and `ANTHROPIC_AUTH_TOKEN` are written as empty strings (removing them would let a *user-level* router apply to a lane that asked for native), while the six model names are removed (writing them would override the user's own global model choice).
|
||
|
||
The lane's `router_profile` column is set in the same call. Like `tool`, it is absent from the lane `PATCH` allowlist: the row and the config on disk may only move together.
|
||
|
||
**An edit reaches the lanes that already use the profile.** Attaching *copies* a profile — a claude lane carries the key and base URL in its own settings file from then on — so a store-only write would be inert exactly where it matters: rotating a leaked key in Settings would leave every claude lane authenticating with the revoked one. `PATCH` therefore re-applies the profile to each lane naming it, and `DELETE` resets those lanes to native (on disk and in the DB) and removes the Codex layer file. Both report `lanes: { updated: <n>, failed: ["<id> (<title>): <code>", …] }`; a lane whose directory is gone or whose settings file a human has broken is reported there, never fatal to the edit.
|
||
|
||
---
|
||
|
||
## 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"] } }
|
||
```
|
||
|
||
#### 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.
|
||
|
||
```json
|
||
{ "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.
|
||
|
||
```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).
|