- tmux targets are exact (`=name`, `=name:` for panes/options): a bare `-t agenthub-lane-1` resolves by prefix to agenthub-lane-12 once lane 1 is gone, so killing a finished lane could kill another. - getRun/killRun refuse ids that are not `agenthub-lane-<n>`, so DELETE /api/run/main can no longer kill a user's own tmux session. - router/apply reports restart_required only for a running pane; getRun returns a `gone` handle, so it was always true. - needs_action is cleared only by the session that raised it (new nullable lanes.needs_action_session); a second session in the same cwd used to clear it on its second hook.
122 KiB
API Reference
Complete REST API and WebSocket documentation for Agent Dashboard.
Table of Contents
- Overview
- Authentication
- Base URL
- REST API
- WebSocket API
- Error Handling
- Rate Limiting
- Pagination
- Examples
Overview
The Agent Dashboard API provides programmatic access to Claude Code session monitoring data.
graph LR
Client[API Client] -->|HTTP/HTTPS| REST[REST API<br/>:4820/api/*]
Client -->|WebSocket| WS[WebSocket<br/>:4820/ws]
REST --> DB[(SQLite)]
WS --> Broadcast[Real-time<br/>Broadcasts]
style REST fill:#10B981
style WS fill:#F59E0B
style DB fill:#003B57,color:#fff
Protocols:
- REST API - HTTP/JSON for queries and mutations
- WebSocket - Real-time event streaming
Authentication
The server is local-first and is hardened to keep the dashboard off the network by default (see GHSA-gr74-4xfh-6jw9). The trust boundary is the loopback bind, layered with origin and host checks:
- Loopback bind by default — the server binds
127.0.0.1, so it is not network-reachable out of the box. Operators opt into a wider bind withDASHBOARD_HOST(e.g.DASHBOARD_HOST=0.0.0.0for LAN access), which logs a startup warning. - CORS restricted to loopback origins — cross-origin web pages cannot read API responses. Requests with no
Origin(curl, server-to-server) still work. - Host-header allowlist — both HTTP requests and WebSocket upgrades are checked against an allowlist to block DNS-rebinding. Add extra LAN names (when you bind beyond loopback) via
DASHBOARD_ALLOWED_HOSTS(comma-separated).
For deliberate LAN exposure, set DASHBOARD_HOST to a non-loopback address and list the names clients use in DASHBOARD_ALLOWED_HOSTS.
Optional token (DASHBOARD_TOKEN)
Authentication is off by default (the loopback bind is the trust boundary). When DASHBOARD_TOKEN is set, every /api/* request and the WebSocket must present the token. It is strongly recommended whenever you bind beyond loopback. Pass it any of these ways:
Authorization: Bearer <token>headerx-dashboard-token: <token>header?token=<token>query parameter
These paths stay exempt even when a token is configured: /api/health, /api/openapi.json, /api/docs, and /api/hooks (local Claude Code hook ingestion). Requests that fail the check get 401 with error code EUNAUTHORIZED.
GET /api/settings/info includes server.version (the running dashboard release). Pair with agenthub version or the Settings About panel to confirm client and server builds match after deploy.
sequenceDiagram
participant Client
participant API
participant Auth
participant Resource
Client->>API: Request + DASHBOARD_TOKEN
API->>Auth: Validate token (if configured)
Auth-->>API: Valid
API->>Resource: Fetch Data
Resource-->>API: Return Data
API-->>Client: 200 OK + Data
Base URL
http://localhost:4820
For production, use HTTPS:
https://dashboard.example.com
REST API
Lanes
Lane mutations (POST /api/lanes/ensure, POST /api/lanes/worktree,
PATCH /api/lanes/:id, confirmed
actions, the runtime routes POST /api/lanes/:id/up, /down and
/hook/:name, and DELETE /api/lanes/:id) enforce the same loopback same-origin
guard as /api/run: browser requests must originate from localhost,
127.0.0.1, ::1, or 0.0.0.0; CLI and curl requests without Origin pass.
Every lane object returned by GET /api/lanes and GET /api/lanes/:id
additionally carries the server's stage-detection heuristic (see
docs/LANES.md#stage-detection for the full rules, and
server/lib/stage-detect.js for the matcher):
detected_stage— stage id inferred from ingested tool events, ornullif no signal has been seen. Independent of the agent's own declaredstage.detected_signal— the capped (≤120 char) tool-event string that produceddetected_stage;nullwhendetected_stageisnull.detected(boolean, on each entry ofpipeline_nodes) —truefor the inferred node and any node before it that carries no declared record. Decorates that node's existingstate; never upgrades it todone— an inferred stage is never evidence.
Find or adopt the lane owning a directory
POST /api/lanes/ensure
{ "cwd": "/absolute/path/to/work", "title": "App package", "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. exact: true makes only a lane on this very directory count: without
it a cwd nested inside another lane returns that parent lane (created: false); with it the nested directory gets a lane of its own. The Add-lane
modal sends it, and says so when the directory already has a lane. An id no template defines returns 400 EBADPIPELINE.
tool ("claude" or "codex", default "claude") is the CLI this lane's
runs will spawn. Immutable once the lane is created; an attempt to update it on
an existing lane returns 400 EBADTOOL. The tool choice appears as an immutable
badge on the lane card (Codex lanes only).
Idempotent lookup by working directory — the Workspace page opens on a cwd,
not on a lane id. Returns the lane whose own cwd is that path, or the longest
path-boundary parent of it, as { "lane": {...}, "created": false } with 200.
When no lane owns the path, an adopted lane is created and returned as
{ "lane": {...}, "created": true } with 201; title is used only in that
case. A relative cwd returns 400 EBADCWD; a missing or empty cwd
falls back to the user's home directory instead of erroring. Path boundaries
are respected: a lane at /tmp/wt owns /tmp/wt/pkg but never /tmp/wt-sibling.
Two concurrent calls for the same path always yield one lane: the
lanes.cwd UNIQUE constraint decides the winner, and the loser re-reads and
returns the winning lane rather than reporting a conflict.
Update a lane
PATCH /api/lanes/:id
Partially updates a lane. This route is same-origin guarded because its patch
can set run_id.
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
POST /api/lanes/worktree
Creates a dashboard-owned lane from an existing absolute git repository. The
request returns immediately with 202 and lane.status: "provisioning"; the
dashboard completes the git worktree add in the background and broadcasts the
existing lane_update message when the lane becomes idle or failed.
{
"sourceRepo": "/absolute/path/to/repository",
"title": "Criteria form",
"base": "main",
"tool": "claude"
}
tool ("claude" or "codex", default "claude") is the CLI this lane's
runs will spawn. Immutable once the lane is created; an attempt to update it on
an existing lane returns 400 EBADTOOL.
sourceRepo must exist, be absolute, and be a git repository with at least
one commit — a repo with no commits has nothing to branch from and is refused
with 400 EEMPTYREPO before any lane row is created (adopt it as a Repo lane
with POST /api/lanes/ensure instead, commit, then publish). When base is
omitted, it defaults to the LANE_BASE_BRANCH env var, or main when that is
also unset. The feature branch is named <LANE_BRANCH_PREFIX><slug> (prefix
defaults to feat/). title (or an optional slug) determines the
safe branch and directory suffix. If that directory already exists, the slug is
suffixed with -2, -3, and so on, up to 50 attempts. A computed cwd that
already belongs to another lane returns 409 EDUPCWD; exhausting the directory
attempts returns 409 EWORKTREEDIRCOLLISION. A failed provision keeps its
managed lane row with the git diagnostic in notes; use DELETE /api/lanes/:id
to forget that row because no worktree exists to remove.
Start a lane's run
POST /api/lanes/:id/start
Starts a new run in the lane's working directory, or attaches an existing idle tmux session. Returns the updated lane. The request body carries:
{
"prompt": "Write a test for the user endpoint",
"model": "claude-opus-5",
"permissionMode": "plan",
"effort": "medium",
"resumeSessionId": "session-xyz",
"sandbox": "read-only",
"approval": "on-request"
}
All fields are optional. The tool (CLI invoked) comes from the lane itself, never the request — a client cannot override a lane's tool. If the lane is a Codex lane, three fields control Codex-specific behavior:
-
sandbox— one ofread-only,workspace-write, ordanger-full-access, or omitted/empty for no sandbox. Passed tocodex -s <value>. -
approval— one ofon-requestornever, or omitted/empty for no approval prompt. Passed tocodex -a <value>. -
resumeSessionIdorresumeLast: true— resume an earlier run instead of starting new. For Codex lanes specifically,resumeLast: trueusescodex resume --last;resumeSessionIdusescodex resume <sessionId>. For Claude lanes,resumeSessionIdre-attaches a Claude session by id. -
slot— which Workspace split pane's session to start,0–3(default0, else400 EBADSLOT). Slot 0 is the lane's ownagenthub-lane-<id>and is whatrun_idrecords; slots 1–3 are extra sessions on the same lane,agenthub-lane-<id>-<slot>, and never touchrun_id/status. The response addsrun_id: the session this start opened.stop,resetandremovekill every slot's session, not onlyrun_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
GET /api/lanes/:id/preflight?action=reset|remove|purge
Returns the exact facts a user must confirm. reset and remove return
head, dirty, untracked, unpushed, database, blocked, and warnings;
purge returns sessions, events, tokenRows,
bytesEstimate, and activeSessionSkipped. expect is required: reset/remove
must include all of head, dirty, untracked, and unpushed (not database
— see below); purge must
include sessions, events, and tokenRows. Missing or incomplete
confirmation facts return 400 EEXPECT.
database is the name a reset (unless keepDb: true) or remove will drop
— null when the lane has no slot yet or its profile declares no DB_PREFIX.
It is not part of the staleness-checked expect set: it is derived from the
lane's slot and profile, not from mutable git/session state, so it cannot go
stale between preflight and the action. See
docs/LANES.md#data-isolation-database-redis-and-env-a2.
blocked reports conditions that affect the action: adopted
(not a managed worktree), missing (directory gone), unreadable (directory
exists but git failed against it), and unpushed-commits (unpushed count > 0
— the only one force: true overrides). Which of them actually prevent the
action depends on the action. They all block reset. None of them blocks
remove: the server forgets an adopted lane's row, prunes a hand-deleted
worktree, and for an unreadable one attempts git worktree remove --force —
if git itself refuses (e.g. a corrupt worktree .git pointer), the server
deregisters it directly from the source repo's bookkeeping instead, without
ever touching the directory. So for remove they are context rather than
refusals — a client must not disable confirmation on them.
warnings holds purely informational
facts that never gate the action: currently just no-remote (no git remote
configured — nothing here is backed up remotely, but the action proceeds).
unpushed counts the commits the action would actually discard: commits on no
remote when remotes exist, otherwise the commits ahead of the lane's
base_branch (its own work), falling back to the total commit count only for a
lane with no base at all. A freshly provisioned worktree therefore reports 0,
not the whole repository's history.
Reset, remove, or purge a lane
POST /api/lanes/:id/reset
POST /api/lanes/:id/remove
POST /api/lanes/:id/purge
All three actions require { "confirm": true } and a complete expect object,
and run under the lane lock. They stop the lane run and wait for the spawned
child's actual exit event (including the SIGKILL escalation window) before
performing the action. A failed spawn is treated as already exited because no
child process started or can touch the lane directory. A child that does not
exit returns 500 ERUNTIMEOUT and does not run git. reset and remove additionally require
{ "force": true } when preflight reports unpushed > 0.
{
"confirm": true,
"force": true,
"keepDb": false,
"expect": {
"head": "9b3e74a",
"dirty": 4,
"untracked": 11,
"unpushed": 2
}
}
For purge, expect contains sessions, events, and tokenRows from its
preflight. If any echoed fact changed, the action returns 409 ESTALE with its
expected and current diagnostics, without resetting, removing, or purging.
Missing force for unpushed work returns 409 EUNPUSHED.
reset restores the managed worktree's feature branch from its base, cleans
untracked files but preserves ignored files, clears the lane stage state, and —
when the lane's profile declares data isolation (docs/LANES.md#data-isolation-database-redis-and-env-a2)
— refreshes .env, re-runs bootstrap, clears LANE_DIRS, and drops +
recreates + migrates + reseeds the database, unless keepDb: true is passed
(reset only; skips that whole block, leaving the database untouched).
remove removes the managed git worktree, prunes it, deletes its feature branch,
drops the lane's database and its _test sibling (best-effort; never for an
adopted lane), then deletes the lane row. purge returns the deleted counts:
{
"ok": true,
"purged": { "sessions": 2, "events": 14, "tokenRows": 2 }
}
Only dashboard-managed worktrees can be reset. For remove, a managed lane
uses guarded worktree removal and branch deletion; an adopted lane only has its
dashboard row forgotten, and its directory is never changed. 400 preserves
ENOTMANAGED, EOUTSIDEROOT, and ENOTWORKTREE guard failures on the managed
destroy path. Git failures return 500 with their git stderr in error.stderr.
Read a lane's working copy
GET /api/lanes/:id/git
What the lane's directory looks like to git right now:
{
"available": true,
"branch": "feat/rename-metric",
"head": "9b3e74a",
"unborn": false,
"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 }
],
"hasRemote": true,
"upstream": "origin/feat/rename-metric",
"ahead": 2,
"behind": 1
}
dirty,untrackedandfilescover only the lane's own directory, with cwd-relative, unquoted paths — a lane that is a subdirectory of its repo does not count its siblings' files (the same scoping as/git/changes).- A
cwdthat is missing, is not a git repository, or makes git fail returns{"available": false}with HTTP 200 — a normal state, not a fault. - A repo with no commit yet (a fresh
git init) is available too: it reportsunborn: true,head: null,subject: ""and the branch the first commit will land on. filesnames the files the counts came from, capped at 20 (the counts themselves are never capped).statusis the two-charactergit status --porcelaincode;pathis the new path for a rename.added/deletedcome from onegit diff --numstat HEAD— staged and unstaged work together — and arenullwhen git has no delta to report: an untracked file, a binary file, a rename, or a repo with no commits yet.hasRemote,upstream,aheadandbehinddescribe the branch's standing against its remote-tracking ref. A branch that has never been pushed reportsupstream: nullwithahead/behindnull — not0, which would claim "in sync" about a comparison that was never possible.- Not folded into
GET /api/laneson purpose: it shells out to git five times, and the lane list is polled and re-broadcast on every hook. Seedocs/LANES.mdfor the full treatment.
Read a lane's code changes
GET /api/lanes/:id/git/changes?range=work|branch|commit[&sha=][&path=<file>][&full=1][&context=N]
One route, three depths — the shapes the Workspace's Changes panel asks for:
| Query | Shape | Used by |
|---|---|---|
| (none) | every changed file with its counts, no patch text | overview |
full=1 |
the same list, each file carrying its hunks |
review |
path=<file> |
that one file, with its hunks |
single-file view |
context=N |
the same, with N unchanged lines around each hunk | the viewer's expand button |
range picks the comparison: work (default) is HEAD vs the working tree with
untracked files included, branch is <base>...HEAD for the lane's
base_branch, and commit shows the single commit named by sha (an
unresolvable one is 400 EBADREV, never an empty diff).
context defaults to 3 and is clamped to 5000 — at the top of that
range the diff has effectively become the whole file, which is what "expand
all" means in the panel.
Every path on the lane git surface (reads and writes) is relative to the
lane's cwd, even when that cwd is a subdirectory of its repo: changes
outside the cwd are not listed and cannot be named. Paths are literal file
names, never pathspec globs — new*.js or app/[id]/page.js names exactly
that file.
{
"available": true,
"range": "work",
"base": "development",
"baseRef": "origin/development",
"sha": null,
"context": 3,
"truncated": false,
"files": [
{
"path": "server/lib/git-ops.js",
"status": " M",
"untracked": false,
"staged": false,
"unstaged": true,
"conflicted": false,
"added": 12,
"deleted": 3,
"binary": false,
"hunks": [
{
"header": "@@ -1,2 +1,3 @@",
"context": "runGitAction",
"oldStart": 1,
"newStart": 1,
"lines": [{ "type": "add", "oldLine": null, "newLine": 2, "text": " const x = 1;" }]
}
]
}
]
}
- Each line is already resolved to both gutters (
oldLine/newLine, one of themnullon an added or removed line), so a client renders rows without re-parsing@@headers. staged,unstagedandconflictedcome from the porcelain XY columns and are reported forrange=workonly — they are what the panel's per-file stage/unstage buttons key off.- An untracked file has no git-side "before", so its patch is synthesized
as one all-added hunk from the file itself. A binary file reports
binary: trueandhunks: []. - Three caps guard the response: 300 files listed, 200 KB of patch per
file, 1 MB of patch in a
fullread. A cap that bit setstruncatedon the response and on the file it cut. - Read-only, no same-origin guard.
available: false(HTTP 200) for a cwd git cannot read;400 ENOBASEwhenrange=branchon a lane with no base branch, and400 ENOFILECHANGEfor apathwith no changes in that range.
Read a lane's commits, a file, or the file tree
GET /api/lanes/:id/git/log?limit=30[&file=<path>]
GET /api/lanes/:id/git/file?path=<path>[&rev=work|HEAD|<sha>]
PUT /api/lanes/:id/git/file {path, content, baseSha}
GET /api/lanes/:id/git/ls?q=<substring>
GET /api/lanes/:id/git/tree?path=<dir>
GET /api/lanes/:id/git/tree-all
The reads behind the Changes panel's History, whole-file view, quick-open
and file explorer. All four are read-only and unguarded, and all four answer
{available:false} with HTTP 200 for a cwd git cannot read.
/git/log→{available, commits:[{sha, short, subject, author, date, refs}]}, newest first,limitclamped to 200.file=narrows it to the commits touching that path — relative, no..segment, else400 EBADPATH. Reading one commit's diff is/git/changes?range=commit&sha=.PUT /git/filesaves the whole-file editor (same-origin guarded;server/lib/lane-file-write.js). Same path guards as the read, plus: the file must already exist, be text and within the 1 MB cap, andbaseShamust equal theshait was opened at — otherwise409 ESTALE, never an overwrite.403 ESECRETforsettings.local.json;400 EBADPATH,ENOFILE,EBADCONTENT,ETOOBIG,EBINARY. Answers the file re-read, freshshaincluded. CRLF endings and the final newline are preserved./git/file→{available, path, rev, binary, truncated, lines, sha?}(shaonly for a whole, textualworkfile: what a save sends back).rev=work(default) is the file on disk — the one branch that reads the filesystem, and it is realpath-confined to the lane directory (a symlink resolving outside it isEBADPATH); anything else goes throughgit show <rev>:./<path>(cwd-relative). At EVERY rev the path must be relative with no..segment (else400 EBADPATH), and it is normalised (./a//b→a/b, also thepathechoed back). Binary content is reported, never returned. Capped at 1 MB — a larger file istruncated: true, neverENOFILE(a working-tree file is read only up to the cap, never whole).400 EBADPATH,EBADREVorENOFILE..claude/settings.local.json(where a lane's AI Router key lives) is403 ESECRETat EVERY rev and at ANY depth (sub/.claude/settings.local.json, a subdirectory lane's key, as seen from a lane at the repo root), and so is any path that RESOLVES to it (a symlinked.claude→conf/makes itconf/settings.local.json). In fact ANY file namedsettings.local.json, in any directory and at any rev, is refused — a symlink target's name is all a lane above it, or a commit of it, ever sees. Its hunks are never returned by/git/changeseither./git/ls→{available, paths, truncated, total}, capped at 500. It readsgit ls-files, so ignored files never appear and no skip-list has to be maintained; basename hits rank first./git/tree→{available, path, entries:[{name, path, dir, status?, changed?}], truncated}— one directory level: tracked files plus untracked ones git does not ignore (ls-files --cached --others --exclude-standard), directories folded and listed first. A changed file carries its porcelainstatus(M,??,A,D…), a folder with anything changed under itchanged: true.400 EBADPATHfor a path that escapes the lane./git/tree-all→{available, levels: {"<dir>": [{name, path, dir}]}, truncated}— every level of the same tree (entries decorated the same way) from ONEgit ls-files, each level shaped and capped like/git/tree's; the Files tab's Expand all folders is this one request instead of one per folder.
Read a lane's own branches
GET /api/lanes/:id/git/branches
{"available": true, "branches": [...], "current": "feat/x", "worktrees": [...]}
— the local branches of the lane's own repo, for the branch switcher. The repo
comes from the lane row, never from the query (that is what GET /api/lanes/branches is for, which takes a candidate path for a lane that does
not exist yet).
Act on a lane's working copy
POST /api/lanes/:id/git/:action
The git writes the dashboard offers. Guarded (same-origin) and serialized
per git index (the lane lock keyed on the worktree's git dir), so a claude and
a codex lane on one cwd — or a root and a subdirectory lane of one repo —
cannot collide on index.lock.
| Action | Runs | Body |
|---|---|---|
fetch |
git fetch --all --prune |
— |
pull |
git pull --ff-only |
— |
push |
git push -u <remote> HEAD — the branch's upstream remote, else the only remote, else origin, else 400 ENOREMOTE |
— |
stage |
git add -- <paths> |
{"paths": ["..."]} |
unstage |
git restore --staged -- <paths> (a staged rename also passes its original path, so both halves unstage) |
{"paths": ["..."]} |
discard |
git restore --source=HEAD --staged --worktree (tracked) / git clean -fd (untracked) |
{"paths": ["..."], "confirm": true} |
stage-hunk |
git apply --cached of ONE hunk of git diff -U<context> HEAD -- <path> |
{"path": "...", "hunk": "@@ -1,2 +1,3 @@", "context": 3} |
discard-hunk |
git apply -R of that one hunk, off the working tree |
{"path": "...", "hunk": "@@ ... @@", "context": 3, "confirm": true} |
commit |
git add -A then git commit -m <message>, or the index as it stands with only: "staged" |
{"message": "...", "only": "staged"} |
switch |
git switch [-c] <branch> |
{"branch": "...", "create": false} |
branch-create |
git branch <branch> [<base>] — never switches |
{"branch": "...", "base": "..."} |
branch-delete |
git branch -d <branch> |
{"branch": "..."} |
publish |
git remote add origin -- <url> then git push -u origin HEAD — only when the repo has NO remote |
{"url": "https://host/owner/repo.git"} |
merge |
git merge --no-edit <ref> — a local or remote-tracking branch that exists |
{"branch": "origin/main"} |
resolve |
a conflicted file: checkout --ours|--theirs + add, or add once hand-edited (done) |
{"path": "...", "side": "ours|theirs|done"} |
merge-commit |
git commit --no-edit — concludes the merge |
— |
merge-abort |
git merge --abort |
{"confirm": true} |
{ "action": "commit", "ok": true, "output": "[feat/x 9b3e74a] fix: the thing" }
pullis always--ff-onlyandswitchrefuses a dirty tree (400 EDIRTY): neither can invent a merge commit or a conflict underneath a running agent. There is deliberately no rebase, reset or stash here —resetstays behind the destroy guard onPOST /api/lanes/:id/reset.- Merge (issue #45) happens only when asked for by name: on a clean tree
(
400 EDIRTY), from a lane at the repo root (400 ESUBDIR— a merge is repo-wide), for a branch listed underrefs/headsorrefs/remotes(400 ENOBRANCH). A merge that stops on conflicts is still a 200, withconflicts: ["path", …]; a clean one answersconflicts: []. While a merge is in progresspull,switch,merge,stage-hunkanddiscard-hunkare409 EMERGING, andGET /api/lanes/:id/gitreportsmerging: trueplusconflicts(unmerged files, repo-wide).resolveaccepts only a path git reports as conflicted (400 EBADPATH) and asideofours/theirs/done(400 EBADSIDE);doneand a plainstageof a conflicted file both refuse while a conflict marker is left in it (400 EMARKERS). A side that deleted the file resolves by removing it.merge-commitis409 ECONFLICTSwhile anything is unmerged, and it andmerge-abortare409 ENOMERGEwith no merge in progress.merge-abortneedsconfirm: true(400 ECONFIRM). pushpushesHEAD, never a branch name from the request, so a lane can only push the branch it is on.- A path in the body is never trusted as a path:
stage,unstageanddiscardaccept only pathsgit statusis reporting as changed right now, so.., an absolute path and an untouched file are all400 EBADPATH. Acommitwithoutonly: "staged"sweeps in (git add -A -- .) only the lane cwd's own changes, and is400 ENOCHANGESwhen the lane has none — even if the repo is dirty elsewhere. discardanddiscard-hunkare the irreversible actions here and both requireconfirm: true(400 ECONFIRMwithout it). The UI additionally asks twice.- A hunk is named by its
@@line and matched against a FRESH diff at thecontextthe caller read it with (send thecontextthe changes read returned), so a hunk the file has moved past is409 EHUNKGONE, never a stale patch applied somewhere else. The path goes through the samegit statuscheck; an untracked file has no hunks (400 EUNTRACKED). A hunk that no longer applies (e.g. part of it is already staged) is409 EGIT. branch-createnever switches — the usual reason to create one from a lane is to spawn a sibling worktree lane, and moving this pane's HEAD under a working agent is not.branch-deleteis-d, never-D, and refusesmain/master/development/develop(400 EPROTECTED), the current branch and a branch another worktree holds (400 EBRANCHBUSY).publishis the one write whose destination comes from the request, so it only ever gives a remote to a repo that has none:400 ENOCOMMITSbefore the first commit,400 EREMOTEEXISTSonce any remote exists (it never re-points one), and the name is alwaysorigin. The URL is allowlisted —https://with no userinfo (a token in the URL would land in.git/config),ssh://and scp-likeuser@host:path; a local path,file://,ext::,fd::,git://,http://and anything starting with-are400 EBADURL. If the push fails,originis removed again, so a typo or a missing credential can simply be retried. The response adds"remote": "origin".- Bad input is 400 (
EBADACTION,EBADMESSAGE,EBADBRANCH,EBADPATH,ECONFIRM,EUNTRACKED,EPROTECTED,ENOBRANCH,EBRANCHEXISTS,EBRANCHBUSY,EDIRTY,ENOCHANGES,ESTAGEDOUTSIDE,ENOREMOTE,EBADURL,EREMOTEEXISTS,ENOCOMMITS); git itself exiting non-zero (a rejected push, a diverged pull) is 409EGITwith git's ownstderrappended toerror.message— a rejection reads better in git's words. Anything else (git not installed, a timeout kill) is 500, still under a string code (EGITwhen there is none) —error.codeis never git's numeric exit status. - In a lane whose cwd is a subdirectory of its repo,
commitrefuses with400 ESTAGEDOUTSIDEwhile the index holds a change outside that directory:git committakes the whole index whatever the cwd, and scoping it with a pathspec would commit the working-tree copy (unstaged edits included). - Network actions (
fetch/pull/push/publish) are killed after 60s so an unreachable remote fails instead of holding the request open. - A remote that wants a login (issue #45) is 409
EGITAUTH, notEGIT: the dashboard's git has no terminal to prompt in. The error carriesauth: { protocol, host, username, rejected }—rejected: truewhen a login WAS sent and refused. Resend the same action withcredentials: { protocol, host, username, password, remember }: accepted only for a host that is one of the repo's own remotes (400 EBADHOSTotherwise), used for that one command, and saved to AgentHub's store (below) only after git succeeded ANDrememberis true. Empty or multi-line values are400 EBADCRED.
Git credentials
GET /api/git-credentials Saved logins: { items: [{ protocol, host, username, savedAt, hasToken }] }
POST /api/git-credentials Save or replace — Body: { protocol, host, username, password }
DELETE /api/git-credentials/:protocol/:host Delete one (404 ENOTFOUND)
AgentHub's OWN git logins, one per protocol + host, shared by everyone using
this install; the Git credentials tab of Settings
(/settings?tool=git-credentials) and the git bar's login form both write
here. Stored in <CLAUDE_HOME>/agenthub-git-credentials/credentials.json
(dir 0700, file 0600). No response ever carries a password. Writes are
same-origin guarded; a bad protocol/host is 400 EBADHOST, an empty login
400 EBADCRED.
A network action reaches git with -c credential.helper= -c credential.helper=<AgentHub's helper> whenever AgentHub holds a login for one
of the repo's remote hosts (or the request carries one) — ONLY AgentHub's
helper, so a stale token in the machine's helper cannot win over the one just
typed, and git's post-success store cannot copy a use-once token into the
OS user's helper. Otherwise the command is unchanged and the machine's own
helpers answer as before. The secret never reaches argv: the helper reads the
store file, or a use-once login from its own process env.
Read a lane's runtime
GET /api/lanes/:id/runtime
What is actually running for this lane, recomputed on every call from pid files
and port probes — never cached, because a process can die without telling
anyone. See docs/LANES.md#lane-runtime-running-a-lanes-own-stack.
{
"available": true,
"provisioned": true,
"slot": 3,
"kind": "managed",
"hooks": ["bootstrap", "boot", "health"],
"profileDir": "/work/myapp/.agenthub/profile",
"services": [{ "name": "api", "pid": 40213, "alive": true }],
"ports": { "api": { "port": 8103, "expected": 8003, "listening": true } },
"database": { "name": "myapp_l3", "testName": "myapp_l3_test" },
"redisIndex": 3,
"steppedAside": true,
"up": true,
"healthy": true,
"logs": ["boot.log", "health.log", "api.log"],
"logDir": "/home/you/.claude/agenthub-lanes/.state/lane3/logs",
"lastError": null
}
databaseisnullwhen the profile declares noDB_PREFIX;redisIndexisnullwhenREDISis not1. Both are names/indices only — never a connection string, so this endpoint never leaks the~/.agenthub/secrets.envpassword even though it derivesDATABASE_URLinternally to run hooks.- A lane whose repository declares no
.agenthub/profilereturns{"available": false, "searched": [...]}with HTTP 200 — the same contract asGET /:id/git. Most lanes never run a stack; that is a normal state, not a fault. - A lane with a profile but no slot yet returns
{"available": true, "provisioned": false, "hooks": [...], "ports": {}}. expectedisPORT_BASE_<name> + slot; whenportdiffers, the allocator stepped aside from a number already in use andsteppedAsideistrue.- Not folded into
GET /api/laneson purpose: it opens a socket per declared port and stats every pid file, and the lane list is polled and re-broadcast on every hook.
Boot or stop a lane's stack
POST /api/lanes/:id/up { "build": true }
POST /api/lanes/:id/down
up returns 202 and boots in the background, because a build can run for
minutes. Progress streams as lane_hook_output WebSocket messages and the
attempt finishes with a lane_runtime message carrying the fresh facts (or an
error). build: false passes --no-build to the boot hook.
up runs the profile's boot then health hooks; it does not run
bootstrap. A failing health leaves the processes running so their logs
remain readable, and records EUNHEALTHY in the runtime's lastError.
down is synchronous, idempotent, and a no-op for a lane that was never up:
{ "ok": true, "killed": [40213, 40219], "runtime": { "…": "…" } }
Both write only slot and ports on the lane row. Neither writes stage,
status, or notes — in AgentHub those describe the agent's work, not the stack's
state. Adopted lanes may be brought up and down.
Errors: 400 ENOPROFILE (with the paths searched), 409 ESLOTS (every slot
taken), 409 EPORTBUSY (with port and the occupying pids), 404 ENOLANE.
Run a profile hook
POST /api/lanes/:id/hook/:name { "args": ["--scope", "smoke"] }
Runs one of the profile's hooks — the surface a driving session uses for
ci-gate, e2e, migrate and friends. Returns 202; output streams as
lane_hook_output and completion arrives as lane_hook_result with the exit
code.
:name is checked against a fixed allowlist (bootstrap, boot, health,
migrate, seed, ci-gate, e2e, regen, db-create, db-drop) before
anything is spawned, and args travels as an array of strings straight into
argv — neither is ever joined into a command string. An unknown name returns
400 ENOHOOK with the allowed list; a lane with no slot returns 409 ENOSLOT.
Tail a lane's log
GET /api/lanes/:id/logs/:svc?tail=65536
{ "available": true, "svc": "api", "size": 20481, "truncated": false, "text": "…" }
tail is the number of trailing bytes (default 64 KiB, capped at 1 MiB). The
resolved path is confined to the lane's log directory after realpath, so a
name from the request can never escape it; anything else is 404 ENOLOG. A lane
with no slot returns {"available": false}.
Lane features
Per-feature state and archive (server/lib/lane-features.js) — a lane's
stage/status/etc. is always the LIVE view of whichever feature it's
currently on; these routes read and switch between a lane's feature history.
GET /api/lanes/:id/features
Every feature this lane has ever activated, archived or live, most recently touched first:
{
"features": [
{
"id": 5, "lane_id": 12, "slug": "auth-redesign", "title": "auth-redesign",
"branch": "feat/auth", "pipeline": "default", "stage": "implement",
"stage_since": "2026-08-04T09:00:00.000Z", "status": "running",
"gate_decision": null, "ci_status": null, "qc_dev": null,
"stages": {}, "links": {}, "notes": null, "archived_at": null,
"created_at": "2026-08-04T09:00:00.000Z", "updated_at": "2026-08-04T09:00:00.000Z",
"pipeline_name": "Default", "pipeline_nodes": [ /* same shape as a live lane's */ ], "progress": 40
}
]
}
Each row is the hydrated lane_features row (stages/links parsed from
JSON) plus pipeline_name/pipeline_nodes/progress, computed the exact
same way a live lane's are — the client renders an archived feature with the
same PipelineMap component, no special-casing. archived_at is null for
the currently-active feature, an ISO timestamp for every past one. slug is
always the canonicalized form (drops a leading feat/, turns / and
whitespace into -, keeps [A-Za-z0-9._-], preserves case — not the same
rule as worktree.js:slugify, which lowercases everything).
GET /api/lanes/:id/features/:slug
One feature by its canonicalized slug, same shape as an entry above, wrapped
as { "feature": {...} }. 404 ENOFEATURE if that slug was never activated
on this lane; 404 ENOLANE if the lane itself doesn't exist.
POST /api/lanes/:id/features/activate
{ "slug": "feat/Auth Redesign", "title": "Auth redesign (optional)" }
Switches the lane to a feature by slug:
- If a different feature is currently active, it is archived first with
its exact live state (stage, status, gate/CI fields, stages, links, notes)
— its own
titleis 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 (
titledefaults 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/emptyslug, 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.
GET /api/lanes/:id/proof
List every feature with proof (or an activated-but-not-yet-shot feature from lane_features), most recently touched first:
{
"features": [
{
"slug": "auth-redesign",
"groups": {
"desktop": ["01-login.png", "02-signup.png"],
"mobile": ["01-login.png"]
},
"ticket_report": "auth-redesign/ticket/REPORT.html",
"mtime": 1722755400
}
]
}
Each feature has:
slug(string) — canonicalized feature sluggroups(object) —{ "phase": ["image1.png", "image2.png"] }— phase-groups with their imagesticket_report(string) — relative path to ticket report HTML, or empty if none existsmtime(number) — Unix seconds of the most recent file touched in this feature's proof tree (0 if the feature has no proof yet)
Status codes:
- 200 — success
- 404
{ "error": { "code": "ENOLANE", "message": "lane not found" } }— lane doesn't exist
GET /api/lanes/:id/proof/:slug/:group/:file
Fetch a single proof file (PNG, JPG, or HTML). The file is resolved with path containment checks before serving:
Status codes:
- 200 — file content,
Content-Typeset toimage/png,image/jpeg, ortext/html - 404 — lane, slug, group, or file not found, or path escapes containment
DELETE /api/lanes/:id/proof/:slug
Delete proof at one of three granularities: request body { "group": "phase-name", "images": ["file1.png"] } deletes specific images from a phase, { "group": "phase-name" } deletes an entire phase, or empty {} deletes all phases except ticket/.
{ "deleted": 3 }
Status codes:
- 200 — success, returns count of deleted files
- 400
{ "error": { "code": "EBADPATH", "message": "…" } }— invalid slug, group, or image name; or attempt to deleteticket/ - 404
{ "error": { "code": "ENOLANE", "message": "lane not found" } }— lane doesn't exist - 404
{ "error": { "code": "ENOFEATURE", "message": "…" } }— slug doesn't exist
POST /api/lanes/:id/proof-link
Converge the clone-root proof/ directory (if it exists at <lane.cwd>/proof) onto the canonical .playwright-mcp/proof. This is an explicit operation, never automatic — exposed only as agenthub lanes proof-link. Idempotent.
Response:
{ "linked": true }
linked(boolean) —trueif the link was created or fixed,falseif the symlink already pointed to the right place or a collision was avoided
Status codes:
- 200 — success
- 404
{ "error": { "code": "ENOLANE", "message": "lane not found" } }— lane doesn't exist
Pipeline pass statistics
GET /api/lanes/pipeline-stats
How often each pipeline node was actually passed, aggregated over every 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 passall) for every lane.
{
"pipelines": [
{
"id": "default",
"name": "Default feature pipeline",
"runs": 4,
"nodes": [
{ "id": "plan", "label": "plan", "icon": "🧭", "gate": false,
"entered": 4, "passed": 4, "withEvidence": 4, "failed": 0, "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 fromentered: 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 bylane, 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 whoseresultis"fail";passedis the rest, sopassed + failed === entered.withEvidence— the subset of passes that carry evidence. Tracked apart frompassedbecause a claim and a proof are not the same fact — the same distinction the pipeline map draws betweendoneandpassed-no-evidence.
Status codes:
- 200 — success (an empty
pipelinesarray when no lane has recorded a stage yet, or whenlanenames an id that no longer exists) - 400
{ "error": { "code": "EBADLANE", "message": "lane must be an id" } }—laneis not a positive integer
Workflows
Skill and plugin usage
GET /api/workflows/skill-usage?status=active|completed
Skill invocations counted from Skill tool events — PreToolUse rows only,
since every call also emits a PostToolUse and counting both would double
every number. status filters by session lifecycle, same convention as
GET /api/workflows; omit it (or pass all) for no filter.
{
"skills": [
{ "name": "agenthub:ship-feature", "plugin": "agenthub", "count": 12, "sessions": 5 },
{ "name": "daily-report", "plugin": null, "count": 3, "sessions": 3 }
],
"plugins": [
{ "name": "agenthub", "skills": 1, "skillCalls": 12, "mcpCalls": 4 }
]
}
skills— top 30 by call count.pluginis theplugin:skillprefix, ornullfor a built-in or personal skill.plugins— per-plugin totals: distinct skills used, their calls, and calls to the MCP tools that plugin ships (mcp__plugin_<plugin>_<server>__*), so a plugin used only through MCP does not report zero.
Status codes: 200 always; 500 with { "error": { "message" } } on a
query failure.
Locks
Cross-lane named locks (server/lib/named-lock.js) — the OTHER axis from a
lane's own internal serialization: a name held by one lane at a time, across
ALL lanes, for a step that thrashes the shared machine (a build, an e2e run).
Every route here is single-shot — it never blocks waiting for a lock to
free up. A caller that wants to wait polls POST /:name/acquire itself (the
agenthub lock acquire CLI does exactly this, printing a status line every ~60s
of continued waiting); the server never orchestrates that loop.
List locks
GET /api/locks
Every currently-held lock:
{
"locks": [{ "name": "build", "holder": "lane3", "since": 1722702012, "ageSec": 41 }]
}
since is a Unix seconds timestamp; ageSec is derived at read time. An
empty array means nothing is held.
Acquire a lock
POST /api/locks/:name/acquire
{ "holder": "lane3" }
holder is required — this route does not default it; agenthub lock acquire resolves lane<slot> for the calling lane and sends it explicitly.
A holder older than LOCK_MAX_HOLD (default 2700s, floored at 300s — see
docs/LANES.md#cross-lane-named-locks) is broken automatically before the
attempt.
- 200
{ "acquired": true }— lock claimed. - 409
{ "acquired": false, "holder": "lane1", "since": 1722701900, "ageSec": 150 }— already held by someone else (fresh enough not to be broken). - 400
{ "error": { "code": "EBADHOLDER", "message": "holder is required" } }— missing/emptyholder.
Release a lock
POST /api/locks/:name/release
{ "holder": "lane3" }
- 200
{ "ok": true }— released. - 409
{ "error": { "code": "ENOTHOLDER", "message": "...", "currentHolder": "lane1" } }—holderdoesn't match the current owner; a release never trusts its caller, same rule every other destructive path in this project follows. - 404
{ "error": { "code": "ENOLOCK", "message": "no such lock: build" } }— nothing by that name is held.
Sessions
List Sessions
GET /api/sessions
Returns all sessions, ordered by most recent activity.
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
limit |
integer | 50 | Maximum sessions to return (1-1000) |
offset |
integer | 0 | Pagination offset |
status |
string | - | Filter by persisted status: active, completed, error, abandoned. The UI Waiting state is derived from the awaiting_input_since column and is not a queryable enum — filter status=active and inspect awaiting_input_since (non-null = Waiting) |
sources |
string | - | Comma-separated data-source ids to include (the built-in local history is local; remote SSH machines use their remote_sources.id). Omit for all sources. Also accepted on /api/events, /api/agents, /api/stats, /api/analytics, and /api/pricing/cost. See Remote Data Sources |
Example Request:
curl http://localhost:4820/api/sessions?limit=10&status=active
Example Response:
{
"sessions": [
{
"id": 1,
"session_id": "sess_abc123",
"model": "claude-sonnet-4",
"status": "active",
"total_cost": 1.23,
"agent_count": 3,
"tool_count": 12,
"created_at": "2024-03-18T12:00:00Z",
"updated_at": "2024-03-18T14:30:00Z"
}
],
"total": 42,
"limit": 10,
"offset": 0
}
Response Schema:
classDiagram
class SessionListResponse {
+Session[] sessions
+number total
+number limit
+number offset
}
class Session {
+string id
+string name
+string status "active|completed|error|abandoned"
+string cwd
+string model
+string started_at
+string ended_at
+string updated_at
+string awaiting_input_since "null unless Waiting"
+string awaiting_reason "notification|stop|session_start|interrupted; null unless Waiting"
+number cost
+number agent_count
}
SessionListResponse --> Session
Get Session
GET /api/sessions/:id
Returns single session details.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Session ID (e.g., sess_abc123) |
Example Request:
curl http://localhost:4820/api/sessions/sess_abc123
Example Response:
{
"session": {
"id": 1,
"session_id": "sess_abc123",
"model": "claude-sonnet-4",
"status": "active",
"total_cost": 1.23,
"created_at": "2024-03-18T12:00:00Z",
"updated_at": "2024-03-18T14:30:00Z"
}
}
Error Responses:
| Code | Description |
|---|---|
| 404 | Session not found |
| 500 | Internal server error |
Get Session Stats
GET /api/sessions/:id/stats
Returns aggregated counts powering the Session Detail overview panel. All aggregation runs in SQL — the response is cheap to compute even for sessions with tens of thousands of events.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Session ID |
Example Request:
curl http://localhost:4820/api/sessions/sess_abc123/stats
Example Response:
{
"session_id": "sess_abc123",
"total_events": 14082,
"events_by_type": [
{ "event_type": "PreToolUse", "count": 5210 },
{ "event_type": "PostToolUse", "count": 5208 }
],
"tools_used": [
{ "tool_name": "Bash", "count": 1842 },
{ "tool_name": "Read", "count": 1340 }
],
"error_count": 12,
"first_event_at": "2026-04-26T18:59:00.000Z",
"last_event_at": "2026-04-29T21:30:14.000Z",
"agents": {
"total": 12,
"main": 1,
"subagent": 11,
"compaction": 5,
"by_status": { "completed": 11, "working": 1 }
},
"subagent_types": [
{ "subagent_type": "Explore", "count": 4 }
],
"tokens": {
"input_tokens": 1376,
"output_tokens": 760304,
"cache_read_tokens": 337641891,
"cache_write_tokens": 5126047
}
}
Error Responses:
| Code | Description |
|---|---|
| 404 | Session not found |
| 500 | Internal server error |
Get Session Agents
GET /api/sessions/:id/agents
Returns all agents for a session.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Session ID |
Example Request:
curl http://localhost:4820/api/sessions/sess_abc123/agents
Example Response:
{
"agents": [
{
"id": "sess_abc123-main",
"session_id": "sess_abc123",
"name": "Main Agent - my-project",
"type": "main",
"subagent_type": null,
"status": "idle",
"current_tool": null,
"task": null,
"started_at": "2024-03-18T12:00:00Z",
"ended_at": null,
"updated_at": "2024-03-18T12:05:00Z",
"parent_agent_id": null,
"awaiting_input_since": "2024-03-18T12:05:00Z",
"awaiting_reason": "stop",
"cost": 0
}
]
}
Note on
cost—/api/agentsand/api/sessions/:id/agentsattach acost(USD) to each agent: the agent's own cost, computed server-side from the per-agent token buckets stored inagents.metadata.tokensand priced at the current pricing rules (at the agent's start date, so promo/standard cutovers apply — see Pricing). It is0for main agents (whose cost is the session total, reported by/api/pricing/cost/:sessionId), for compaction pseudo-agents, and for any subagent whose transcript is unavailable. This lets a subagent card show only what that subagent spent instead of the whole session's total.
Note on
statusvs Waiting — agents are persisted with one ofidle | connected | working | completed | error. The yellow Waiting badge surfaced in the dashboard is a UI overlay derived fromawaiting_input_sincebeing non-null on a non-terminal agent (typicallyidleafter aStop, orconnectedright afterSessionStart). Filter?status=idleon/api/agentsand inspectawaiting_input_sinceto enumerate currently-waiting main agents.
Agents
Get Agent
GET /api/agents/:id
Returns single agent details.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Agent ID (e.g., agent_xyz789) |
Example Request:
curl http://localhost:4820/api/agents/agent_xyz789
Example Response:
{
"agent": {
"id": 1,
"agent_id": "agent_xyz789",
"session_id": "sess_abc123",
"agent_type": "explore",
"status": "completed",
"current_tool": null,
"input_tokens": 1500,
"output_tokens": 800,
"cost": 0.45,
"created_at": "2024-03-18T12:00:00Z",
"updated_at": "2024-03-18T12:05:00Z"
}
}
Get Agent Tools
GET /api/agents/:id/tools
Returns tool executions for an agent.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Agent ID |
Example Request:
curl http://localhost:4820/api/agents/agent_xyz789/tools
Example Response:
{
"tools": [
{
"id": 1,
"agent_id": "agent_xyz789",
"tool_name": "bash",
"duration_ms": 1234,
"success": 1,
"error_message": null,
"created_at": "2024-03-18T12:01:00Z"
},
{
"id": 2,
"agent_id": "agent_xyz789",
"tool_name": "view",
"duration_ms": 45,
"success": 1,
"error_message": null,
"created_at": "2024-03-18T12:02:00Z"
}
]
}
Tool Execution Flow:
sequenceDiagram
participant Agent
participant PreHook as PreToolUse Hook
participant Tool as Tool Execution
participant PostHook as PostToolUse Hook
participant DB as Database
Agent->>PreHook: Tool about to execute
PreHook->>DB: Set current_tool
Agent->>Tool: Execute (bash, view, etc.)
Tool-->>Agent: Result
Agent->>PostHook: Tool completed
PostHook->>DB: Create tool_execution record
PostHook->>DB: Clear current_tool
PostHook->>DB: Update token counts + cost
Tools
List All Tools
GET /api/tools
Returns all tool executions across all sessions.
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
limit |
integer | 100 | Max tools to return |
tool_name |
string | - | Filter by tool name |
success |
boolean | - | Filter by success status |
Example Request:
curl http://localhost:4820/api/tools?limit=50&tool_name=bash
Example Response:
{
"tools": [
{
"id": 1,
"agent_id": "agent_xyz789",
"tool_name": "bash",
"duration_ms": 1234,
"success": 1,
"error_message": null,
"created_at": "2024-03-18T12:01:00Z"
}
],
"total": 156
}
Metrics
Prometheus exposition
GET /api/metrics
Exposes the dashboard's live counters in the Prometheus text-exposition format (v0.0.4) so this monitoring dashboard can itself be scraped into Prometheus / Grafana. Read-only. Values are read from the same prepared statements the REST API uses, so they match the UI.
Response Content-Type: text/plain; version=0.0.4; charset=utf-8.
| Metric | Type | Labels | Meaning |
|---|---|---|---|
agenthub_up |
gauge | — | 1 when the API served the scrape |
agenthub_build_info |
gauge | version |
Always 1; dashboard version rides on the label |
agenthub_process_uptime_seconds |
gauge | — | Server process uptime |
agenthub_process_resident_memory_bytes |
gauge | — | Server process RSS |
agenthub_sessions |
gauge | status (active/completed/error/abandoned) |
Sessions by status |
agenthub_agents |
gauge | status (working/waiting/completed/error) |
Agents by status |
agenthub_events_total |
counter | — | Total events recorded |
agenthub_websocket_clients |
gauge | — | Connected realtime clients |
agenthub_remote_sources |
gauge | enabled (true/false) |
Configured Remote Data Sources |
agenthub_tokens_total |
counter | kind (input/output/cache_read/cache_write) |
Cumulative token usage |
Status series are always emitted (even at 0) so a series never disappears from the exposition. The endpoint is mounted under /api, so it sits behind the same two guards as every other route: the Host-header (DNS-rebinding) guard and the optional DASHBOARD_TOKEN guard. A scraper that reaches the server as anything other than loopback (e.g. Prometheus in Docker hitting host.docker.internal) must be allowlisted with DASHBOARD_ALLOWED_HOSTS, or the scrape returns 403 EBADHOST; if a token is set, the scrape must also send it.
Example scrape config (start the server with DASHBOARD_ALLOWED_HOSTS=host.docker.internal):
scrape_configs:
- job_name: agenthub
metrics_path: /api/metrics
static_configs:
- targets: ["host.docker.internal:4820"]
# authorization: # only if DASHBOARD_TOKEN is set
# credentials: "<DASHBOARD_TOKEN>"
Pricing
List Pricing Rules
GET /api/pricing
Returns all pricing rules (default + custom).
Example Request:
curl http://localhost:4820/api/pricing
Example Response:
{
"rules": [
{
"id": 1,
"pattern": "claude-sonnet-4",
"input_cost_per_1m": 3.0,
"output_cost_per_1m": 15.0,
"is_default": true,
"created_at": "2024-03-18T12:00:00Z"
},
{
"id": 10,
"pattern": "gpt-5.1-codex",
"input_cost_per_1m": 2.5,
"output_cost_per_1m": 10.0,
"is_default": false,
"created_at": "2024-03-18T14:30:00Z"
}
]
}
Pricing Rule Matching:
graph TB
Model[Model Name<br/>e.g., claude-sonnet-4] --> Match{Match Pattern?}
Match -->|Exact Match| Custom[Use Custom Rule]
Match -->|Substring Match| Default[Use Default Rule]
Match -->|No Match| Fallback[Use Generic Fallback]
Custom --> Calculate[Calculate Cost]
Default --> Calculate
Fallback --> Calculate
Calculate --> Result[input_cost + output_cost]
style Calculate fill:#10B981
Create or Update Pricing Rule
PUT /api/pricing
Upsert a pricing rule, keyed by model_pattern. The same call creates a new rule or updates an existing one (matched on model_pattern). Rates are per million tokens.
Request Body:
{
"model_pattern": "claude-sonnet-5%",
"display_name": "Claude Sonnet 5",
"input_per_mtok": 3,
"output_per_mtok": 15,
"cache_read_per_mtok": 0.3,
"cache_write_per_mtok": 3.75,
"cache_write_1h_per_mtok": 6,
"fast_input_per_mtok": 0,
"fast_output_per_mtok": 0,
"intro_until": "2026-08-31",
"intro_input_per_mtok": 2,
"intro_output_per_mtok": 10,
"intro_cache_read_per_mtok": 0.2,
"intro_cache_write_per_mtok": 2.5,
"intro_cache_write_1h_per_mtok": 4
}
Fields:
| Field | Type | Constraints |
|---|---|---|
model_pattern |
string | Required. SQL-style glob; % matches any characters (e.g. claude-opus-4-7%) |
display_name |
string | Required |
input_per_mtok / output_per_mtok |
number | Standard per-MTok rates (default 0) |
cache_read_per_mtok / cache_write_per_mtok / cache_write_1h_per_mtok |
number | Cache rates (default 0) |
fast_input_per_mtok / fast_output_per_mtok |
number | Fast-mode premium rates (default 0) |
intro_until |
string | null | Optional promo cutoff YYYY-MM-DD. Usage on or before this date is priced at the intro_* rates, after it at the standard rates. Empty/null clears the promo (and zeroes the intro rates) |
intro_*_per_mtok |
number | Optional introductory (promo) rates, mirroring the standard fields |
The intro block is optional and backward-compatible: a request that omits every intro_*/intro_until field leaves any existing promo untouched, so older clients that send only the standard rates never clobber a promo.
Validation: every *_per_mtok rate present in the body must be a non-negative finite number (numeric strings are coerced); a NaN, non-numeric, or negative value is rejected with 400 INVALID_INPUT naming the offending field, and nothing is written. intro_until must be a YYYY-MM-DD date (or empty/null to clear the promo).
Example Request:
curl -X PUT http://localhost:4820/api/pricing \
-H "Content-Type: application/json" \
-d '{
"model_pattern": "gpt-5.1-codex",
"display_name": "GPT-5.1 Codex",
"input_per_mtok": 2.5,
"output_per_mtok": 10.0
}'
Example Response:
{
"pricing": {
"model_pattern": "gpt-5.1-codex",
"display_name": "GPT-5.1 Codex",
"input_per_mtok": 2.5,
"output_per_mtok": 10.0,
"intro_until": null,
"updated_at": "2026-07-01T14:30:00Z"
}
}
Error Responses:
| Code | Description |
|---|---|
| 400 | Missing model_pattern/display_name, or intro_until not a YYYY-MM-DD date |
| 500 | Database error |
Delete Pricing Rule
DELETE /api/pricing/:pattern
Delete custom pricing rule (default rules cannot be deleted).
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
pattern |
string | Pattern to delete (URL-encoded) |
Example Request:
# Pattern must be URL-encoded
curl -X DELETE http://localhost:4820/api/pricing/gpt-5.1-codex
Example Response:
{
"deleted": true
}
Error Responses:
| Code | Description |
|---|---|
| 404 | Pattern not found |
| 403 | Cannot delete default rule |
| 500 | Database error |
Notifications
Get Session Notifications
GET /api/sessions/:id/notifications
Returns notifications for a session.
Path Parameters:
| Parameter | Type | Description |
|---|---|---|
id |
string | Session ID |
Example Request:
curl http://localhost:4820/api/sessions/sess_abc123/notifications
Example Response:
{
"notifications": [
{
"id": 1,
"session_id": "sess_abc123",
"notification_type": "backgroundTaskComplete",
"message": "Explore agent completed",
"created_at": "2024-03-18T12:05:00Z"
}
]
}
Remote Data Sources
The /api/remote-sources/* namespace configures remote SSH machines the dashboard pulls Claude Code history from, so one dashboard can consolidate sessions from several machines. No secrets are stored — SSH authentication defers entirely to the host's SSH stack (ssh-agent, ~/.ssh/config, key files). Every imported session is tagged with the source's id in the sessions.source column (the built-in local history uses the id local), which powers the sources filter below.
RemoteSource shape:
{
"id": "4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11",
"label": "Work laptop",
"host": "son@studio.local",
"ssh_port": 22,
"identity_file": "~/.ssh/id_ed25519",
"remote_home": "~/.claude",
"enabled": true,
"status": "ok",
"last_error": null,
"last_sync_at": "2026-07-22T18:41:55.117Z",
"last_sync_counts": {
"imported": 9,
"skipped": 41,
"backfilled": 0,
"errors": 0,
"sessions_seen": 50,
"sessions_tagged": 50
},
"created_at": "2026-07-20T09:15:00.000Z",
"updated_at": "2026-07-22T18:41:55.117Z"
}
ssh_port, identity_file, remote_home, last_error, last_sync_at, and last_sync_counts are nullable. status is one of idle, syncing, ok, error.
List Remote Sources
GET /api/remote-sources
Returns all configured remote sources. Response: { "sources": RemoteSource[] }.
Create Remote Source
POST /api/remote-sources
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
label |
string | Yes | Human-readable name |
host |
string | Yes | SSH destination (user@host) or a ~/.ssh/config alias |
ssh_port |
integer | No | SSH port (defers to SSH default / config when omitted) |
identity_file |
string | No | Private-key path passed to ssh (-i) |
remote_home |
string | No | Remote Claude home (defaults to remote ~/.claude) |
Cursor (informational): Sessions imported from
~/.claudeinclude Cursor agent usage on that machine too — Cursor happens to use the same paths as Claude Code. AgentHub does not tag which app created a session.
| enabled | boolean | No | Whether the source is eligible for syncs (default true) |
Returns { "source": RemoteSource } with HTTP 201.
Error Responses (400): { "error": { "code", "message" } } with one of:
| Code | Meaning |
|---|---|
INVALID_LABEL |
Missing/blank label |
INVALID_HOST |
Missing/invalid host |
INVALID_PORT |
ssh_port out of range |
INVALID_IDENTITY_FILE |
Invalid identity_file value |
INVALID_REMOTE_HOME |
Invalid remote_home value |
Update Remote Source
PATCH /api/remote-sources/:id
Partial update — only the keys present in the body change. Same fields (and the same validation codes) as create; both label and host are optional here. Returns { "source": RemoteSource }, or 404 if the id is unknown.
Delete Remote Source
DELETE /api/remote-sources/:id
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
purge |
boolean | false |
When true, also delete this source's imported sessions. When omitted/false, those sessions are detached — reassigned to the local source so history is preserved |
Returns { "ok": true, "purged": <bool> } (purged is true only when ?purge=true deleted the sessions). 404 if the id is unknown.
Test Remote Source
POST /api/remote-sources/:id/test
Runs an SSH connectivity probe. Returns { "ok": <bool>, "message": <string>, "remoteProjects?": string[] } — remoteProjects lists the discovered remote project directories on success. Does not import anything. 404 if the id is unknown.
Sync Remote Source
POST /api/remote-sources/:id/sync
Pulls Claude Code history from the remote over SSH now, through the same idempotent import pipeline used locally, tagging imported sessions with this source's id. Progress/completion is also broadcast over the WebSocket as remote_source.status frames.
Example Response:
{
"ok": true,
"imported": 9,
"skipped": 41,
"backfilled": 0,
"errors": 0,
"sessions_seen": 50,
"sessions_tagged": 50
}
404 if the id is unknown; 500 with { error: { code: "SYNC_FAILED", message } } on SSH/import failure.
Sync All Remote Sources
POST /api/remote-sources/sync-all
Pulls history from every enabled source sequentially (one SSH connection at a time). Per-source failures are isolated — one unreachable machine never aborts the others — and each outcome is returned in results. Always 200.
Example Response:
{ "ok": true, "synced": 2, "results": [{ "id": "src_a", "ok": true }, { "id": "src_b", "ok": false, "error": "ssh exited with code 255" }] }
The sources filter
GET /api/sessions, /api/events, /api/agents, /api/stats, and /api/analytics accept an optional sources query parameter: a comma-separated list of source ids to include (omit for all). GET /api/sessions/facets correspondingly returns a sources: string[] array (alongside cwds) listing the distinct sessions.source values so the UI can build the filter dropdown.
curl "http://localhost:4820/api/sessions?sources=local,4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11"
Claude Config Explorer
The /api/cc-config/* namespace powers the Claude Config Explorer, now the Claude Code tab of Settings (/settings?tool=claude-code). All read endpoints are pure file reads under CLAUDE_HOME and the project's .claude/ dir. File mutations cover the low-risk text-file artifacts (skills, subagents, slash commands, output styles, memory), keybindings.json, and settings.json layers via a per-key merge only — each always creates a timestamped backup before writing. Plugin/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.
GET /api/cc-config/overview
GET /api/cc-config/skills?scope=user|project|all&cwd=<dir>
GET /api/cc-config/agents
GET /api/cc-config/commands?scope=user|project|all&cwd=<dir>
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 }
POST /api/cc-config/profiles/usage 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? }
The list reads take an optional cwd naming the project whose .claude/ is read (default: the server's own cwd); the run console's / autocomplete passes the directory the run will start in, so a lane's own project commands and skills are the ones offered.
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. The two commands are typed one second apart (SETTLE_MS in server/lib/cc-reload.js), which is why the route takes about that long to answer: Claude Code opens its slash-command menu the moment / is typed and the Enter that follows lands on the menu, so back-to-back sends left both commands on one composer line and the pane submitted /reload-plugins/reload-skills — run as /reload-plugins with an argument, never reloading skills at all. 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. On macOS the live claudeAiOauth is NOT that file — the claude CLI keeps it in the login Keychain (generic password, service Claude Code-credentials, account $USER), read/written via /usr/bin/security; the file is only a fallback (and is kept in step when one already exists). AGENTHUB_CC_KEYCHAIN=0 pins the file.
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).
POST /profiles/usage (how much quota this account has spent). Answers { name, refreshed, fiveHour, sevenDay, checkedAt }, where each window is { utilization, resetsAt } — a percentage and a rollover time, nothing else. There is no local source for this: nothing under CLAUDE_HOME records a utilization, and the credentials file carries only subscriptionType and rateLimitTier, so the server asks the same endpoint the CLI's own /usage reads (GET https://api.anthropic.com/api/oauth/usage) with that profile's OAuth token.
It is a POST, and behind the same-origin guard, because answering it is a write. A saved profile's access token lasts hours, so every profile that is not the active one is normally expired; reading its usage means refreshing it first, and the refresh rotates the stored credentials — Anthropic retires the refresh token it was handed. The new pair is therefore written straight back into the profile file (atomic temp + rename at 0600, every other field preserved), because a refresh whose result is dropped leaves an account that can never be activated again without a fresh claude auth login. A token that is still valid is used as-is and nothing is rewritten; a token that looks valid but is refused (401) is retried once through the refresh path.
The account that is logged in right now is the exception, and it is read from the CLI. A refresh token is single use, so every refresh Claude Code performs on its own account retires the copy sitting in that account's profile file — the saved credentials of the LIVE account are the one set guaranteed to be rejected, with invalid_grant, however recently the profile was saved. cc-profiles.liveCredentialsFor therefore matches the profile's accountUuid against .credentials.json and, on a hit, uses what the CLI is holding. That token is never refreshed and that file is never written: rotating it would log the user out of their own terminal. An expired live token answers ELIVEEXPIRED (409) — using Claude Code once makes the CLI refresh it.
Nothing calls this on a timer, but opening the Profiles modal does ask for every saved account at once — comparing accounts is the only reason the panel exists, and a button per row made the user do that by hand. What keeps that affordable is the pair of rules above: the live account costs a plain read, and any other profile is refreshed only when its token has actually expired, which leaves it valid for hours of further opens. Each row resolves on its own, so one superseded account still lets its neighbours report. Errors: ENOTFOUND (404), ENOREFRESH/ENOAUTH (409 — the profile has no usable token, re-save it from a login), ESTALE (409 — the refresh token was already spent by a newer login or refresh; activate the account once and save the profile again), ELIVEEXPIRED (409), EUNAUTHORIZED (401), anything upstream 502. See server/lib/cc-usage.js.
A profile has two names, and the split is what lets a profile be called Tài khoản chính. name is the internal identifier every mutation route takes, and it is the filename, so it must match ^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$; callers normally never choose it, because POST /profiles derives it from the account. label is the display name the user edits with PATCH /profiles: free-form 1-60 characters, spaces and diacritics allowed since it is never used as a path, stored trimmed, rejected for control characters (EBADLABEL), and unique across profiles case-insensitively (ELABELTAKEN). A rename rewrites the label in place and does not move name, so it cannot half-fail and leave a profile unreachable. Profiles written before labels existed report label equal to their name.
One account is one profile: POST /profiles refuses with EDUPLICATE when another profile already holds the logged-in account, naming the one that does. Two profiles of a single account would both report active: true, leaving nothing to switch between — and, under a blanket active-guard on delete, neither would ever be removable. Re-posting an account into its OWN profile is the token-refresh path, stays allowed, and preserves that profile's label.
The dashboard cannot log an account in — only claude auth login mints tokens. Adding a profile is therefore always "log in from a terminal, then POST /profiles to snapshot what the CLI wrote". POST /profiles/activate re-snapshots the outgoing account into its own profile first (reported as savedOutgoing), so tokens Claude Code refreshed since the last save are not lost, and returns { ok, name, savedOutgoing, written }. Errors: EBADNAME / EBADLABEL (400), ENOTFOUND (404 — no such profile), EBADJSON (409 — a config file on disk is unparseable; nothing is written), ENOAUTH (409 — nobody is logged in to snapshot), EDUPLICATE (409 — another profile already holds this account), ELABELTAKEN (409 — another profile already uses that label), EUNSAVED (409 — the live account is not saved as a profile, and switching away would discard the only copy of its credentials), EACTIVE (409 — refusing to delete the last saved copy of the live account; a redundant copy is deletable, which is what makes an accidental duplicate cleanable). Mutations broadcast cc_config_changed with type: "profile". A switch only takes effect for new Claude Code sessions, and a session still running will rewrite the config on exit — close them first.
Backup paths look like <root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir] — outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. The Backups modal in the UI auto-builds mv restore commands.
Codex Config Explorer
The /api/codex-config/* namespace powers the Codex tab of Settings (/settings?tool=codex) — the Codex CLI counterpart of the Claude Config Explorer above. Codex has a narrower scope union than Claude Code: "user" or "project" only, no "project-local" and no per-project file-based memory. Reads for skills/agents/rules/memory/config are pure file reads (server/lib/codex-discovery.js); MCP servers, plugins, and marketplaces have no on-disk reader and are read and mutated entirely through the codex CLI (server/lib/codex-cli.js), because the CLI owns config.toml's schema.
GET /api/codex-config/overview
GET /api/codex-config/skills?scope=user|project|all
GET /api/codex-config/agents?scope=user|project|all
GET /api/codex-config/rules?scope=user|project|all
GET /api/codex-config/memory
GET /api/codex-config/config
GET /api/codex-config/file?path=<absolute-path>
PUT /api/codex-config/file Body: { scope, type, name?, content }
DELETE /api/codex-config/file Body: { scope, type, name? }
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). tmux must be installed on the dashboard server (same operational requirement as better-sqlite3's native module).
GET /api/run List all live runs (computed fresh from tmux state)
GET /api/run/tmux { available: bool } — whether tmux is on PATH
GET /api/run/binary?tool= { found, path } for the `claude` or `codex` binary
GET /api/run/cwds Suggested cwds (dashboard, home, recent)
GET /api/run/history?limit=&laneId= Persisted run history; laneId narrows to one lane's runs
GET /api/run/files?cwd=&q= Fuzzy file search inside cwd for the @-file autocomplete
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <tmpdir>/agenthub-drops-<uid>/ with a random name, pruned after a day; the terminal pastes the path
(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)
:id must be a lane pane name, agenthub-lane-<n>; anything else answers 404 without reaching tmux, so these two routes can never touch a tmux session the dashboard did not start.
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 (auto default, or acceptEdits / default / plan / bypassPermissions) is the mode the pane starts in — auto is the CLI's own classifier mode: it asks only for what it reads as risky. 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. The response carries restart_required and run_id: a pane already running adopts the new connection but keeps the model it resolved at startup, so it must be restarted before the switch is real. 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 as409 EBADJSONand left untouched..local.jsonrather thansettings.jsonbecause the written value includes an API key. - A codex lane gets
$CODEX_HOME/<name>.config.toml, the layercodex -p <name>loads on top of the base config — Codex has no project-level config, so the baseconfig.tomlis never touched. The layer setswire_api = "responses";"chat"is not an option, codex-cli 0.149 refuses to load a config containing it.env_keyis 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 handsAGENTHUB_ROUTER_KEYto 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. The updated lane is then broadcast as lane_update, same as every other mutating lane route — without it a client's cached row keeps the old router_profile, and re-selecting the lane re-reads that stale row and flips the picker back to the previous profile.
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
const ws = new WebSocket('ws://localhost:4820/ws');
ws.onopen = () => {
console.log('Connected to Agent Dashboard');
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
console.log('Received:', message);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
console.log('Disconnected');
};
When DASHBOARD_TOKEN is configured, pass the token as ?token=<token> on the /ws upgrade (an x-dashboard-token header also works):
const ws = new WebSocket('ws://localhost:4820/ws?token=YOUR_DASHBOARD_TOKEN');
WebSocket Lifecycle
stateDiagram-v2
[*] --> Connecting: new WebSocket()
Connecting --> Connected: onopen
Connecting --> Disconnected: onerror
Connected --> Connected: onmessage
Connected --> Disconnected: onclose
Connected --> Disconnected: onerror
Disconnected --> Connecting: Reconnect
Disconnected --> [*]
note right of Connected
Heartbeat: ping every 30s
Broadcast: Real-time events
end note
Event Types
Server broadcasts JSON messages to all connected clients:
session.created
Sent when a new session is created.
{
"type": "session.created",
"data": {
"id": 1,
"session_id": "sess_abc123",
"model": "claude-sonnet-4",
"status": "active",
"total_cost": 0,
"created_at": "2024-03-18T12:00:00Z",
"updated_at": "2024-03-18T12:00:00Z"
}
}
session.updated
Sent when session data changes (status, cost, etc.).
{
"type": "session.updated",
"data": {
"id": 1,
"session_id": "sess_abc123",
"model": "claude-sonnet-4",
"status": "completed",
"total_cost": 1.23,
"created_at": "2024-03-18T12:00:00Z",
"updated_at": "2024-03-18T14:30:00Z"
}
}
agent.created
Sent when a new agent starts.
{
"type": "agent.created",
"data": {
"id": 1,
"agent_id": "agent_xyz789",
"session_id": "sess_abc123",
"agent_type": "explore",
"status": "running",
"current_tool": null,
"input_tokens": 0,
"output_tokens": 0,
"cost": 0,
"created_at": "2024-03-18T12:00:00Z",
"updated_at": "2024-03-18T12:00:00Z"
}
}
agent.updated
Sent when agent data changes (tokens, status, current_tool).
{
"type": "agent.updated",
"data": {
"id": 1,
"agent_id": "agent_xyz789",
"session_id": "sess_abc123",
"agent_type": "explore",
"status": "completed",
"current_tool": null,
"input_tokens": 1500,
"output_tokens": 800,
"cost": 0.45,
"created_at": "2024-03-18T12:00:00Z",
"updated_at": "2024-03-18T12:05:00Z"
}
}
tool.executed
Sent when a tool execution completes.
{
"type": "tool.executed",
"data": {
"id": 1,
"agent_id": "agent_xyz789",
"tool_name": "bash",
"duration_ms": 1234,
"success": 1,
"error_message": null,
"created_at": "2024-03-18T12:01:00Z"
}
}
notification.received
Sent when a notification is created.
{
"type": "notification.received",
"data": {
"id": 1,
"session_id": "sess_abc123",
"notification_type": "backgroundTaskComplete",
"message": "Explore agent completed",
"created_at": "2024-03-18T12:05:00Z"
}
}
/ws-pty/:runId — PTY frames
A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on lane load; endpoint is /ws-pty/:runId where runId comes from POST /api/run. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, agenthub lanes shell, other tools), all receiving the same frames live-synced. The attach URL carries the client's size (/ws-pty/:runId?cols=<n>&rows=<n>, integers in [2, 1000], else 80×24): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first resize message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: 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 — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
cc_config_changed
Broadcast whenever Claude Code configuration changes — either by dashboard mutations on PUT/DELETE /api/cc-config/file (source: "dashboard") or by lib/cc-watcher.js picking up external fs.watch events on ~/.claude/ and ~/.claude.json (source: "fs", debounced at 500 ms). The Config Explorer page subscribes and refetches automatically.
{ "type": "cc_config_changed", "data": { "source": "dashboard", "action": "write", "scope": "user", "type": "skill", "name": "my-skill" } }
{ "type": "cc_config_changed", "data": { "source": "fs", "paths": ["/Users/foo/.claude/settings.json"] } }
codex_config_changed
The Codex counterpart of cc_config_changed — broadcast by routes/codex-config.js after every successful mutation: PUT/DELETE /api/codex-config/file, and the CLI-backed mcp/add, mcp/remove, plugins/action, marketplaces/add, marketplaces/action routes. Always source: "dashboard" — there is no filesystem watcher on $CODEX_HOME (unlike the Claude side's cc-watcher.js). The Codex Config page subscribes and refetches automatically.
{ "type": "codex_config_changed", "data": { "source": "dashboard", "action": "write", "scope": "user", "type": "rules", "name": "default" } }
{ "type": "codex_config_changed", "data": { "source": "dashboard", "action": "add", "type": "mcp", "name": "docs" } }
remote_data.updated
Broadcast once per successful remote sync (background poller, manual Sync now, or immediate pull after add/re-enable). Clients use this — and the per-session session_created / session_updated frames emitted in the same pass — to refetch sessions, costs, and analytics without polling.
{
"type": "remote_data.updated",
"data": {
"sourceId": "src_a1b2c3",
"source": "src_a1b2c3",
"label": "dev-box",
"counters": { "imported": 1, "skipped": 0, "sessions_tagged": 3 },
"last_sync_at": "2026-07-26T21:15:00.000Z"
}
}
remote_source.status
Broadcast when a remote data source changes sync state (during/after POST /api/remote-sources/:id/sync) or is deleted. status is one of idle, syncing, ok, error, or deleted. error and last_sync_at are optional and present when relevant. See Remote Data Sources.
{ "type": "remote_source.status", "data": { "id": "4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11", "status": "syncing" } }
{ "type": "remote_source.status", "data": { "id": "4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11", "status": "ok", "last_sync_at": "2026-07-22T18:41:55.117Z" } }
{ "type": "remote_source.status", "data": { "id": "4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11", "status": "error", "error": "ssh exited with code 255" } }
{ "type": "remote_source.status", "data": { "id": "4d1f0e2a-7b9c-4c33-8a21-9e0f7b6d4c11", "status": "deleted" } }
Event Flow
sequenceDiagram
participant Hook as Hook Handler
participant Server as Express Server
participant DB as SQLite
participant WS as WebSocket Server
participant Client1 as Client 1
participant Client2 as Client 2
Hook->>Server: POST /hooks/post-tool-use
Server->>DB: Create tool_execution
DB-->>Server: Inserted row
Server->>WS: broadcast({ type: 'tool.executed', data })
par Broadcast to all clients
WS->>Client1: { type: 'tool.executed', ... }
WS->>Client2: { type: 'tool.executed', ... }
end
Server-->>Hook: 200 OK
Error Handling
Error Response Format
All error responses follow this structure:
{
"error": "Human-readable error message",
"code": "ERROR_CODE",
"details": {
"field": "Additional context"
}
}
HTTP Status Codes
| Code | Meaning | Example |
|---|---|---|
| 200 | Success | Resource retrieved |
| 201 | Created | Resource created |
| 400 | Bad Request | Invalid JSON, missing fields |
| 404 | Not Found | Session/agent not found |
| 409 | Conflict | Duplicate pattern |
| 500 | Server Error | Database error |
Error Examples
400 Bad Request:
{
"error": "Missing required field: pattern",
"code": "VALIDATION_ERROR",
"details": {
"field": "pattern",
"message": "Pattern is required"
}
}
404 Not Found:
{
"error": "Session not found",
"code": "NOT_FOUND",
"details": {
"session_id": "sess_invalid"
}
}
409 Conflict:
{
"error": "Pricing rule already exists",
"code": "DUPLICATE_PATTERN",
"details": {
"pattern": "claude-sonnet-4"
}
}
Rate Limiting
Currently, no rate limiting is enforced. For production deployments, implement rate limiting:
// Using express-rate-limit
import rateLimit from 'express-rate-limit';
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
message: 'Too many requests, please try again later.'
});
app.use('/api/', limiter);
Pagination
For endpoints returning lists, use limit and offset:
GET /api/sessions?limit=20&offset=40
Pagination Pattern:
graph LR
Page1[Page 1<br/>offset=0<br/>limit=20] --> Page2[Page 2<br/>offset=20<br/>limit=20]
Page2 --> Page3[Page 3<br/>offset=40<br/>limit=20]
Page3 --> PageN[Page N<br/>offset=N*20<br/>limit=20]
style Page1 fill:#3B82F6
Response includes pagination metadata:
{
"sessions": [...],
"total": 156,
"limit": 20,
"offset": 40,
"has_more": true
}
Examples
Full Session Workflow
// 1. List sessions
const sessions = await fetch('http://localhost:4820/api/sessions');
const { sessions: sessionList } = await sessions.json();
// 2. Get specific session
const sessionId = sessionList[0].session_id;
const session = await fetch(`http://localhost:4820/api/sessions/${sessionId}`);
const sessionData = await session.json();
// 3. Get session agents
const agents = await fetch(`http://localhost:4820/api/sessions/${sessionId}/agents`);
const { agents: agentList } = await agents.json();
// 4. Get agent tools
const agentId = agentList[0].agent_id;
const tools = await fetch(`http://localhost:4820/api/agents/${agentId}/tools`);
const { tools: toolList } = await tools.json();
console.log('Session:', sessionData);
console.log('Agents:', agentList);
console.log('Tools:', toolList);
Real-time Monitoring
// Connect to WebSocket
const ws = new WebSocket('ws://localhost:4820/ws');
ws.onopen = () => {
console.log('Connected to real-time stream');
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
switch (message.type) {
case 'session.created':
console.log('New session:', message.data.session_id);
break;
case 'agent.updated':
console.log('Agent updated:', message.data.agent_id);
console.log('Cost:', message.data.cost);
break;
case 'tool.executed':
console.log('Tool executed:', message.data.tool_name);
console.log('Duration:', message.data.duration_ms, 'ms');
break;
}
};
Creating Pricing Rules
// Create custom rule
const response = await fetch('http://localhost:4820/api/pricing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pattern: 'my-custom-model',
input_cost_per_1m: 5.0,
output_cost_per_1m: 20.0
})
});
const { rule } = await response.json();
console.log('Created rule:', rule);
// List all rules
const rules = await fetch('http://localhost:4820/api/pricing');
const { rules: ruleList } = await rules.json();
console.log('All rules:', ruleList);
// Delete rule
await fetch('http://localhost:4820/api/pricing/my-custom-model', {
method: 'DELETE'
});
Summary
The Agent Dashboard API provides:
- ✅ RESTful endpoints for querying sessions, agents, tools, pricing
- ✅ WebSocket streaming for real-time updates
- ✅ Type-safe responses with consistent JSON structure
- ✅ Error handling with descriptive error codes
- ✅ Pagination for large datasets
- ✅ Pricing management with custom rule support
For interactive API exploration with live request/response examples, see the built-in Swagger UI at /api/docs and ReDoc at /api/redoc. For MCP integration, see MCP.md.