Files
AgentHub/docs/API.md
T
nntrivi2001 b441f32949 Merge branch 'perf/lag': dashboard lag fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	client/src/components/Sagi/PetSprite.tsx
#	client/src/components/Sagi/useSagiBrain.ts
2026-10-14 12:26:41 +07:00

215 KiB
Raw Blame History

API Reference

Complete REST API and WebSocket documentation for Agent Dashboard.


Table of Contents


Overview

The Agent Dashboard API provides programmatic access to Claude Code session monitoring data.

graph LR
    Client[API Client] -->|HTTP/HTTPS| REST[REST API<br/>:4820/api/*]
    Client -->|WebSocket| WS[WebSocket<br/>:4820/ws]
    
    REST --> DB[(SQLite)]
    WS --> Broadcast[Real-time<br/>Broadcasts]
    
    style REST fill:#10B981
    style WS fill:#F59E0B
    style DB fill:#003B57,color:#fff

Protocols:

  • REST API - HTTP/JSON for queries and mutations
  • WebSocket - Real-time event streaming

Authentication

The server is local-first and is hardened to keep the dashboard off the network by default (see GHSA-gr74-4xfh-6jw9). The trust boundary is the loopback bind, layered with origin and host checks:

  • Loopback bind by default — the server binds 127.0.0.1, so it is not network-reachable out of the box. Operators opt into a wider bind with DASHBOARD_HOST (e.g. DASHBOARD_HOST=0.0.0.0 for LAN access), which logs a startup warning.
  • CORS restricted to loopback origins — cross-origin web pages cannot read API responses. Requests with no Origin (curl, server-to-server) still work.
  • Host-header allowlist — both HTTP requests and WebSocket upgrades are checked against an allowlist to block DNS-rebinding. Add extra LAN names (when you bind beyond loopback) via DASHBOARD_ALLOWED_HOSTS (comma-separated).

For deliberate LAN exposure, set DASHBOARD_HOST to a non-loopback address and list the names clients use in DASHBOARD_ALLOWED_HOSTS.

The routes that act on the machine (starting a session, creating a workspace, git writes, workspace sources, locks…) check the browser's Origin (sameOriginGuard). They accept a loopback origin, or an origin that is the request's own Host when that Host is one of DASHBOARD_ALLOWED_HOSTS. Before server mode they accepted loopback only, so a dashboard opened at a LAN name answered those writes with 403 EBADORIGIN. Since then, the writes work from that LAN name too. Set DASHBOARD_TOKEN if the LAN is not trusted.

Optional token (DASHBOARD_TOKEN)

Authentication is off by default (the loopback bind is the trust boundary). When DASHBOARD_TOKEN is set, every /api/* request and the WebSocket must present the token. It is strongly recommended whenever you bind beyond loopback. Pass it any of these ways:

  • Authorization: Bearer <token> header
  • x-dashboard-token: <token> header
  • ?token=<token> query parameter

These paths stay exempt even when a token is configured: /api/health, /api/openapi.json, /api/docs, and /api/hooks (local Claude Code hook ingestion). Requests that fail the check get 401 with error code EUNAUTHORIZED.

Server mode login (AGENTHUB_MODE=server)

A dashboard shared by several people runs in server mode (SERVER-MODE.md). Every /api/* call and WebSocket then needs the login cookie agenthub_sid (httpOnly, SameSite=Strict), except /api/health and /api/auth/*. A call without one answers 401 with code EAUTH. Hooks are accepted only from the host itself (403 ELOCALONLY otherwise). An analyst reads every row but writes only their own, like a developer (404 ENOTFOUND for another user's), and only an admin may write the host's configuration (403 EADMIN).

Route Answer
GET /api/auth/me {mode: "local"}, or {mode: "server", user: {id, username, role} | null}
POST /api/auth/login {username, password} {user} + the cookie; 401 EBADLOGIN, 429 ETOOMANY, 404 ENOTSERVER in local mode
POST /api/auth/logout {ok: true}, cookie cleared

In server mode a developer sees only rows of the workspaces they own (lanes.owner_id): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers 404 ENOTFOUND. Admin and analyst see everything; GET /api/users lists every account (displayName, email, createdAt, logins, lastLoginAt included) with its workspaces, sessions and 7/30-day {cost, tokens}, and GET /api/users/audit[?limit=] the staff reads of developers' sessions and admins' account changes (reader/owner are usernames, reader_display/owner_display their display names or null) (both 403 EADMIN for a developer). See SERVER-MODE.md.

An admin changes accounts with POST /api/users ({username, password, role} → 201 {user}), GET /api/users/:id ({user, workspaces: [{id, title, cwd}]}, the account with logins and lastLoginAt, for the Edit form; admin or analyst), PATCH /api/users/:id (any of {displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true} — every field validated before any is written; removeWorkspaces leaves the workspace with no owner and answers 409 ENOTOWNED for one the user does not own; an admin's own password/signOut keeps the login it came from), POST /api/users/:id/password ({password}; that user's logins end) and POST /api/users/:id/workspaces ({laneId}; the workspace's owner becomes that user). Every one carries currentPassword, the admin's own password: 403 EREAUTH when it is wrong or missing, 429 ETOOMANY after 10 wrong tries in 15 minutes. 409 ESELF for an admin's own role or disable, 409 EEXISTS for a taken name, 400 EBADUSER/EBADPASSWORD/EBADROLE/EBADEMAIL/EBADFIELD, 404 ENOUSER/ENOLANE; a developer or analyst gets 403 EADMIN, and local mode 403 ELOCALMODE. No answer carries a password hash; each change is written to the audit log.

GET /api/accounts[?force=1] lists the Claude account pool (SERVER-MODE.md): {accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], blockPercent} — codex is the Codex account pool. It never includes a key or a token. force=1 re-reads every quota first. Run rows (/api/run/history) carry account, the pool account their pane ran on (null = the machine's own login).

An admin (server mode) adds an account with POST /api/accounts {tool: "claude"|"codex", name, currentPassword, …} → 201 {account}. For Claude the body carries one of: profile (a saved switch-user profile, MOVED in: the profile is deleted; the one the host's CLI is logged into moves in with its live login and the host's home becomes one more placement the server refreshes — 409 EACTIVE only for a macOS Keychain login; name defaults to it), credentials (the text of a .credentials.json; Anthropic is asked whose account it is, 502 EUPSTREAM when it cannot be reached), or token (a claude setup-token token). For Codex, token is an OpenAI API key or the text of an auth.json. GET /api/accounts/profiles lists the saved profiles {profiles: [{name, label, email, subscriptionType, active}]} (no token). Claude rows in GET /api/accounts carry kind (token|login), email and refreshError; a login's expiresAt is null and removes one with DELETE /api/accounts/:name {tool, currentPassword} → {ok: true}. No answer carries the token. 403 EREAUTH wrong password (429 ETOOMANY after 10), 403 EADMIN for a non-admin, 403 ELOCALMODE in local mode, 400 EBADTOOL/EBADNAME/EBADTOKEN, 409 EEXISTS, 404 ENOACCOUNT; each change is audited by account name.

The account's own preferences and first-run setup (server/routes/me.js; local mode is the one account local, server mode one row per account in user_prefs):

Route Answer
GET /api/me/prefs {prefs: {<key>: "<value>"}, onboardedAt}; 401 EAUTH with no login
PUT /api/me/prefs {prefs: {<key>: "<value>" | null}} merged (null deletes a key), the same shape back; 400 EBADPREFS for a key outside the list below or a value that is not a string of at most 8 KB
GET /api/me/instructions {instructions, max, tools} — tools lists the AI tool ids whose new panes receive them (["claude","codex"]; Cursor CLI and Antigravity have no per-session way to take them). The account's own agent instructions (Settings → AI tools → My agent instructions, "" when none); 401 EAUTH with no login
PUT /api/me/instructions {instructions: "<markdown>" | null} replaced, the same shape back; blank or null clears; 400 EBADFIELD not a string, 413 ETOOBIG over 16 000 characters. Put BEFORE the workspace's own instructions in every new Claude/Codex pane of a workspace the account owns (local mode: every workspace) — see Workspace context below
POST /api/me/onboarded the first-run setup is done or skipped (kept from the first call)
GET /api/me/account Settings → Account. Server mode: {mode: "server", user: {id, username, role, disabled, displayName, email, createdAt}, workspaces, sessions, logins, lastLoginAt}; local mode {mode: "local"}
PATCH /api/me/account {displayName?, email?} the account's own profile (""/null clears), the same shape back; 400 EBADFIELD (display name over 100 characters or with control characters) / EBADEMAIL
POST /api/me/password {currentPassword, newPassword} {ok: true}; every other login of the account ends, this one stays. 403 EREAUTH wrong current password (429 ETOOMANY after 10), 400 EBADPASSWORD
POST /api/me/sign-out-others {ended} — every login of the account but this one ends

The keys are the browser's own localStorage names: theme, agenthub.theme.dark, agenthub.theme.light, i18nextLng, agenthub.terminal.font, agenthub.terminal.fontSize, agenthub.ui.fontSize, agenthub.pet, agenthub.pet.size, agenthub.pet.names, agent-dashboard-sagi-enabled, agent-dashboard-sagi-muted, agent-monitor-notifications, agenthub.defaultTool, agenthub.permissionMode (never bypassPermissions — bypass stays the per-session switch), agenthub.tour, and agenthub.chatUi ("1" = Claude/Codex sessions open as a chat; read and written on the server only, by client/src/lib/chatUi.ts). The request never names an account, and writes are same-origin. An account's save writes only their own row, and under S3 a theme of dark or light also becomes the theme in their own ~/.claude/settings.json, written as them.

GET /api/settings/info includes server.version (the running dashboard release). Pair with agenthub version or Settings → System 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

Workspaces

Every /api/lanes… route below also answers under /api/workspaces… — the same router mounted twice (server/index.js), so GET /api/workspaces/:id is GET /api/lanes/:id. Both are permanent: /api/workspaces matches the UI's word, but exists only on servers newer than v2.0.1, so a script that must also reach an older install keeps /api/lanes. Responses keep their lane/lanes keys either way, and the paths below are written with /api/lanes.

Workspace 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 workspace object returned by GET /api/lanes and GET /api/lanes/:id additionally carries the server's stage-detection heuristic (see docs/LANES.md#stage-detection for the full rules, and server/lib/stage-detect.js for the matcher):

  • detected_stage — stage id inferred from ingested tool events, or null if no signal has been seen. Independent of the agent's own declared stage.
  • detected_signal — the capped (≤120 char) tool-event string that produced detected_stage; null when detected_stage is null.
  • detected (boolean, on each entry of pipeline_nodes) — true for the inferred node and any node before it that carries no declared record. Decorates that node's existing state; never upgrades it to done — an inferred stage is never evidence.

Find or adopt the workspace owning a directory

POST /api/lanes/ensure
{ "cwd": "/absolute/path/to/work", "title": "App package", "pipeline": "default", "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 workspace 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 workspace is actually created — an existing workspace is returned untouched. Omitted, the workspace gets default. POST /api/lanes/worktree and POST /api/lanes take the same optional field. exact: true makes only a workspace on this very directory count: without it a cwd nested inside another workspace returns that parent workspace (created: false); with it the nested directory gets a workspace of its own. The Add-workspace modal sends it, and says so when the directory already has a workspace. An id no template defines returns 400 EBADPIPELINE.

tool (an AI Tools catalog id, default "claude") is the CLI this workspace's sessions start on unless a start names another (POST /api/lanes/:id/start tool). Immutable once the workspace is created; an attempt to update it on an existing workspace returns 400 EBADTOOL. The tool choice appears as an immutable badge on the workspace card (Codex workspaces only).

Idempotent lookup by working directory — the Workspace page opens on a cwd, not on a workspace id. Returns the workspace whose own cwd is that path, or the longest path-boundary parent of it, as { "lane": {...}, "created": false } with 200. When no workspace owns the path, an adopted workspace 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 workspace at /tmp/wt owns /tmp/wt/pkg but never /tmp/wt-sibling.

Two concurrent calls for the same path always yield one workspace: the lanes.cwd UNIQUE constraint decides the winner, and the loser re-reads and returns the winning workspace rather than reporting a conflict.

Pick a path with the OS's own dialog

POST /api/lanes/pick-path
Content-Type: application/json

{ "kind": "dir", "start": "/home/me/projects" }

Opens the operating system's folder (kind: "dir", the default) or file (kind: "file") picker on the server's desktop and waits for the user — zenity, else kdialog, on Linux; osascript on macOS; PowerShell's WinForms dialogs on Windows (server/lib/native-dialog.js; macOS and Windows untested). start opens it there (its folder when it names a file; home when missing). Answers { "path": "<absolute>" } or { "cancelled": true }. Same-origin guarded, and only a socket from loopback carrying no X-Forwarded-For / Forwarded header may ask (a reverse proxy connects from loopback too): 403 EREMOTE otherwise, 501 ENODIALOG with no graphical session, no picker program, or one that could not reach the display, 409 EBUSY while one is already open, 500 EDIALOG when the program failed. The dialog is closed when the request goes away (a closed tab, an SSH tunnel's browser leaving). A Browse button in the UI (client/src/components/PathPickButton.tsx) falls back on ANY of those to the in-app browser GET /api/lanes/browse?path=<abs>. A file field asks it with files=1: entries then include the folder's files (file: true, listed after the folders), and path opens the way the native picker's start does (~ expanded, a file's folder, home when missing) — a double-click on a file picks it. hidden=1 lists dot entries too (never those at the top of a non-admin's own folder), every entry carries its mtime (ms) and the answer its home (the browser's Home place). In server mode with AGENTHUB_USERS_ROOT set (under S3, else server.conf's USERS_ROOT), a developer's browser opens on their own folder (parent: null there) and any path whose real path leaves it is 403 EFOLDER. An admin is held to no folder, but one with a Linux account of their own (S3) opens on THEIR home (/srv/agenthub/users/<name>) and the answer carries hostHome (the host account's home); host=1 opens that instead — the browser's Server folder tick, unticked at every open, because a workspace there runs as the host account every admin shares. The browser's New folder is POST /api/lanes/browse/mkdir ({parent, name} → 201 {path}): one plain folder name inside an existing folder, 400 EBADNAME/ENOTFOUND, 409 EEXISTS; a non-admin only inside their own folder, and in server mode not at all without one (403 EFOLDER); a member's folder is made as the member (S3).

Clone a repository into a new workspace

POST /api/lanes/clone   Body: { url, parent, name?, title?, tool?, credentials?: { username, password, remember? } }
                        → 201 { lane, created: true, path }

Add workspace → Clone. url passes the same allowlist as the git bar's publish (https:// with no userinfo, ssh://, scp-like user@host:path — else 400 EBADURL); name (default: the URL's last segment without .git) is one plain folder name that must not exist (400 EBADNAME, 409 EEXISTS); parent follows New folder's rules above (403 EFOLDER, 400 ENOTFOUND). The clone runs as the folder's account (S3: sudo -u ah-<name> git) with that account's OWN identity (see Your git identity below), up to 10 minutes, and the folder then becomes a workspace like POST /ensure (title defaults to the folder name). A remote that wants a login answers 409 ECREDENTIALS with error.auth = { protocol, host } — send it again with credentials (https only, for that host only; remember keeps it in the account's store, otherwise it is forgotten after the clone). 409 ESSHAUTH means the SSH login was refused (add the public key to the host), 409 ENOREPO that the forge has no such repository (or this login cannot see it); a disabled or unknown tool is refused (400 EDISABLED/EBADTOOL) before anything is cloned. An SSH clone uses StrictHostKeyChecking=accept-new (a first contact records the host key, a changed one still fails). The identity used is written into the new repo's own config (core.sshCommand naming the key, or a member's credential.helper=store --file=…), so later fetch/pull/push — the dashboard's and an agent's — use it too; the user's global git config is never touched.

Update a workspace

PATCH /api/lanes/:id

Partially updates a workspace. 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 workspace'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 workspace's cwd. Renaming is display-only: a workspace's branch name was slugified from its title once, at creation.

pipeline is patchable, which is how a workspace moves between templates after creation (agenthub workspace pipeline <template-id>). An id no template defines is rejected with 400 EBADPIPELINE rather than stored (default is always accepted): 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 workspace'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.

Pipelines are dormant until a template is installed. AgentHub ships no pipeline template; templates come only from DASHBOARD_PIPELINES_DIR (see docs/LANES.md#custom-pipeline-templates). With none installed, every workspace is on an empty default pipeline (pipeline_nodes: [], progress: 0), GET /api/lanes/pipelines returns { "pipelines": [] }, and POST /api/lanes/:id/stage answers 400 { "error": { "code": "ENOPIPELINE", "message": "no pipeline template is installed, so this lane has no stages to declare (drop a template into DASHBOARD_PIPELINES_DIR to turn stages on)", "stages": [] } }.

Provision a managed worktree

POST /api/lanes/worktree

Creates a dashboard-owned workspace 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 workspace becomes idle or failed.

{
  "sourceRepo": "/absolute/path/to/repository",
  "title": "Criteria form",
  "base": "main",
  "tool": "claude"
}

tool (an AI Tools catalog id, default "claude") is the CLI this workspace's sessions start on unless a start names another (POST /api/lanes/:id/start tool). Immutable once the workspace is created; an attempt to update it on an existing workspace 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 workspace row is created (adopt it as a Repo workspace 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 workspace returns 409 EDUPCWD; exhausting the directory attempts returns 409 EWORKTREEDIRCOLLISION. A failed provision keeps its managed workspace row with the git diagnostic in notes; use DELETE /api/lanes/:id to forget that row because no worktree exists to remove.

Start a workspace's run

POST /api/lanes/:id/start

Starts a new run in the workspace's working directory, or attaches an existing idle tmux session. Returns the updated workspace. 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.

  • tool — the AI tool THIS session runs on, an AI Tools catalog id (only an id; the command, args and env still come from the catalog file). Omitted = the workspace's own tool. It must be known (400 EBADTOOL) and enabled (400 EDISABLED); the workspace's own tool is never re-checked, so a workspace on a tool disabled since keeps starting when tool is omitted. A resume (resumeSessionId, or resume) ignores tool and keeps the tool its session ran on (sessions.tool). The workspace's own tool column is unchanged. The run is recorded with its tool (dashboard_runs.tool, the run handle's tool).

When the session's tool is Codex, three fields control Codex-specific behavior:

  • sandbox — one of read-only, workspace-write, or danger-full-access, or omitted/empty for no sandbox. Passed to codex -s <value>.

  • approval — one of on-request or never, or omitted/empty for no approval prompt. Passed to codex -a <value>.

  • resumeSessionId or resumeLast: true — resume an earlier run instead of starting new. For Codex workspaces specifically, resumeLast: true uses codex resume --last; resumeSessionId uses codex resume <sessionId>. For Claude workspaces, resumeSessionId re-attaches a Claude session by id.

  • forkSession: true (with resumeSessionId, Claude workspaces only) — resume into a NEW session id (claude --resume <id> --fork-session), leaving the original untouched; the run records no session until the fork's first hook. Without resumeSessionId it is 400 EBADSESSION, on any other tool 400 EUNSUPPORTED.

  • proxy: "<name>" — the AI Proxy profile THIS session runs on (omitted = native, or for a resume the proxy its session last ran on; the pre-rename router field is still accepted); see § AI Proxy profiles. 400 EBADPROXY for an unknown name, 400 EUNSUPPORTED on a tool other than Claude/Codex.

  • worktree: true (Claude workspaces, no resumeSessionId) — a NEW session in a git worktree of its own, Claude Code's way: the pane runs claude --worktree=ah-<id>-<base36 time> (the = form, since -w [name] would take the prompt as the name; the name is made by the server), so Claude itself creates <repo>/.claude/worktrees/<name> on branch worktree-<name>, copies .worktreeinclude, runs the user's WorktreeCreate hook and cleans up on exit. /.claude/worktrees/ is added to the repo root's info/exclude; the run is recorded with worktree: 1 (and the run handle carries worktree: true). 400 EUNSUPPORTED on any other tool. A resumeSessionId (or fork) of a session that ran in such a worktree starts the pane IN that worktree; one whose worktree is gone is 400 ENOWORKTREE.

  • slot — which Workspace session to start, 0–9 (ten per workspace) or "auto" (default 0, else 400 EBADSLOT). "auto" lets the server pick the first slot with no live session, under the workspace lock (a client's own pick may come from a stale list); when all ten are busy it answers 409 ERUNLIVE "every tab of this workspace is busy", while a specific busy slot answers 409 ERUNLIVE "that tab already has a live session". Slot 0 is the workspace's own agenthub-lane-<id> and is what run_id records; slots 1–9 are extra sessions on the same workspace, agenthub-lane-<id>-<slot>, and never touch run_id/status. The response adds run_id: the session this start opened. stop, reset and remove kill every slot's session, not only run_id.

  • shell — true opens a plain shell in that slot instead of an AI tool (the Workspace's New terminal tab, or Open terminal in the workspace home's AI menu): the login shell tmux would run ($SHELL -l) in the workspace's folder, started only once a client is attached, so at the browser's size (#{session_attached} polled, at most 2 s — a prompt printed at tmux's 80 columns and then resized left the cursor on top of it, issue #55), recorded as tool shell. Every other field is ignored, nothing comes from the request but the slot, and the workspace's run_id/status stay as they are (no agent is at work). A slot with a live session answers 409 ERUNLIVE.

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 ESESSIONLIVE is returned when resumeSessionId (or resume) names a session a live pane already holds — two --resumes of one session would be two agents writing one transcript. error.run_id names that pane; open it instead. A fork (forkSession: true) makes a new session and is allowed. A resumeSessionId on a catalog tool (anything but Claude/Codex) is 400 EUNSUPPORTED, since it would silently start fresh.

Error 400 EBADPROMPT is returned for a prompt (or POST /api/run's initialPrompt) over 128 KB: no CLI can take one argument that long (Linux's MAX_ARG_STRLEN, issue #68) — attach the text as a file instead. A prompt over 4 KB never rides in tmux's argv (one tmux message holds 16 KB, command too long): outside Windows it is written to <data dir>/run-prompts/<run id>.txt (0600) and the pane's own sh reads it back as the CLI's last argument and deletes the file. An after-start tool's paste still goes through tmux set-buffer and stays under that 16 KB.

Error 409 ENOCWD is returned when the workspace's folder no longer exists (or is not a directory) — the pane would otherwise start in the account's home under this workspace's name. A folder this server cannot read (EACCES) is not refused. Server mode with one Linux user per account: a missing folder inside a member's own home is created instead, as that member (mkdir -p through write-as) — their home itself can be no workspace, so this is how a new member's first project folder comes to exist.

Error 409 ERUNLIVE is returned if the workspace already has a live run; stop it first with the stop action. The same code and status come back when the workspace'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 workspace 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 workspace 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 workspace'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 workspace'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 workspace's base_branch (its own work), falling back to the total commit count only for a workspace with no base at all. A freshly provisioned worktree therefore reports 0, not the whole repository's history.

Reset, remove, or purge a workspace

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 workspace lock. They stop the workspace 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 workspace 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 workspace stage state, and — when the workspace'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 workspace's database and its _test sibling (best-effort; never for an adopted workspace), then deletes the workspace 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 workspace uses guarded worktree removal and branch deletion; an adopted workspace 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 workspace's working copy

GET /api/lanes/:id/git

What the workspace'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, untracked and files cover only the workspace's own directory, with cwd-relative, unquoted paths — a workspace that is a subdirectory of its repo does not count its siblings' files (the same scoping as /git/changes).
  • A cwd that is missing, is not a git repository, or makes git fail returns {"available": false} with HTTP 200 — a normal state, not a fault.
  • A repo with no commit yet (a fresh git init) is available too: it reports unborn: true, head: null, subject: "" and the branch the first commit will land on.
  • files names the files the counts came from, capped at 20 (the counts themselves are never capped). status is the two-character git status --porcelain code; path is the new path for a rename.
  • added/deleted come from one git diff --numstat HEAD — staged and unstaged work together — and are null when git has no delta to report: an untracked file, a binary file, a rename, or a repo with no commits yet.
  • hasRemote, upstream, ahead and behind describe the branch's standing against its remote-tracking ref. A branch that has never been pushed reports upstream: null with ahead/behind null — not 0, which would claim "in sync" about a comparison that was never possible.
  • Not folded into GET /api/lanes on purpose: it shells out to git five times, and the workspace list is polled and re-broadcast on every hook. See docs/LANES.md for the full treatment.

Read a workspace'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 workspace's base_branch, and commit shows the single commit named by sha (an unresolvable one is 400 EBADREV, never an empty diff). A branch answer also carries mergeBase — the full sha of git merge-base <baseRef> HEAD, the commit the diff is taken from (null on the other ranges or when git cannot find one); the panel's editor reads a file's original text there.

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 workspace git surface (reads and writes) is relative to the workspace'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",
  "mergeBase": null,
  "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 them null on an added or removed line), so a client renders rows without re-parsing @@ headers.
  • staged, unstaged and conflicted come from the porcelain XY columns and are reported for range=work only — 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: true and hunks: [].
  • Three caps guard the response: 300 files listed, 200 KB of patch per file, 1 MB of patch in a full read. A cap that bit sets truncated on the response and on the file it cut.
  • Read-only, no same-origin guard. available: false (HTTP 200) for a cwd git cannot read; range=branch compares against the workspace's base branch, else the repo's default branch (what origin/HEAD names, else main/master) — 400 ENOBASE when neither exists — and 400 ENOFILECHANGE for a path with no changes in that range.

Read a workspace's commits, a file, or the file tree

GET /api/lanes/:id/git/log?limit=30[&file=<path>][&ref=<branch>]
GET /api/lanes/:id/git/blame?path=<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/raw?path=<image>[&rev=work|HEAD|<sha>]
GET /api/lanes/:id/git/ls?q=<substring>
GET /api/lanes/:id/git/tree?path=<dir>
GET /api/lanes/:id/git/tree-all
GET /api/lanes/:id/git/download?path=<path>
POST /api/lanes/:id/git/upload?dir=<folder>   (X-File-Name header, raw body)

The reads behind the Changes panel's History, whole-file view, quick-open and file explorer. The GETs are read-only and unguarded, and the JSON ones answer {available:false} with HTTP 200 for a cwd git cannot read — except the file explorer's /git/tree, /git/tree-all and /git/ls, which read the disk instead for a folder that is not a git repo (below).

  • /git/log → {available, commits:[{sha, short, subject, author, date, refs, parents}]}, newest first in --date-order (no parent before its children; parents = parent shas, first parent first), limit clamped to 200. file= narrows it to the commits touching that path — relative, no .. segment, else 400 EBADPATH — with each parents REWRITTEN to the file's own previous commit (--parents), so the file's graph is one joined-up line. ref= reads another branch's history instead of HEAD's (where the panel's Cherry-pick finds its commits): a local or remote-tracking branch that exists, else 400 ENOBRANCH. Reading one commit's diff is /git/changes?range=commit&sha=.
  • /git/blame → {available, path, commits: {<sha>: {short, author, date, summary}}, lines: [<sha>|null]} — git blame --porcelain of the working-tree file (Annotate); null = a line not committed yet. Same lexical guard as /git/file, 403 ESECRET, 400 ETOOBIG over 1 MB, 400 ENOFILE; an untracked file answers {available:false}.
  • PUT /git/file saves 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, and baseSha must equal the sha it was opened at — otherwise 409 ESTALE, never an overwrite. 403 ESECRET for settings.local.json; 400 EBADPATH, ENOFILE, EBADCONTENT, ETOOBIG, EBINARY. Answers the file re-read, fresh sha included. CRLF endings and the final newline are preserved.
  • /git/raw → the bytes of one IMAGE file (png, jpg, jpeg, gif, webp, avif, bmp, ico, svg — by extension, which also sets the Content-Type), for the viewer's <img>; the Changes panel shows an image file this way instead of text or a binary diff. Same path/rev/secret guards as /git/file, 20 MB cap; 400 ENOTIMAGE for any other extension, ETOOBIG, ENOFILE, EBADPATH, EBADREV, 403 ESECRET. Served with a sandbox CSP and nosniff, so an SVG opened from this URL runs no script.
  • /git/file → {available, path, rev, binary, truncated, lines, sha?} (sha only for a whole, textual work file: 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 workspace directory (a symlink resolving outside it is EBADPATH); anything else goes through git show <rev>:./<path> (cwd-relative). At EVERY rev the path must be relative with no .. segment (else 400 EBADPATH), and it is normalised (./a//b → a/b, also the path echoed back). Binary content is reported, never returned. Capped at 1 MB — a larger file is truncated: true, never ENOFILE (a working-tree file is read only up to the cap, never whole). 400 EBADPATH, EBADREV or ENOFILE. .claude/settings.local.json (where an older build kept a workspace's AI Proxy key) is 403 ESECRET at EVERY rev and at ANY depth (sub/.claude/settings.local.json, a subdirectory workspace's key, as seen from a workspace at the repo root), and so is any path that RESOLVES to it (a symlinked .claude → conf/ makes it conf/settings.local.json). In fact ANY file named settings.local.json, in any directory and at any rev, is refused — a symlink target's name is all a workspace above it, or a commit of it, ever sees. Its hunks are never returned by /git/changes either. Under S3 (SERVER-MODE.md) a dot folder at the top of a member's home (.claude, .codex, .ssh…) is 403 ESECRET too, and a workspace folder that now resolves into another account's files is 403 EFOREIGN for /git/file, /git/raw, /git/blame and PUT /git/file.
  • /git/ls → {available, paths, truncated, total}, capped at 500. It reads git 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 porcelain status ( M, ??, A , D…), a folder with anything changed under it changed: true. 400 EBADPATH for a path that escapes the workspace.
  • /git/tree-all → {available, levels: {"<dir>": [{name, path, dir}]}, truncated} — every level of the same tree (entries decorated the same way) from ONE git ls-files, each level shaped and capped like /git/tree's; the Changes panel's Files mode's Expand all folders is this one request instead of one per folder.
  • A folder that is not a git repo — /git/tree, /git/tree-all and /git/ls answer from the disk instead (walkFolder in server/lib/git-diff.js), same shapes, no status/changed: confined to the folder's real path, a symlink listed and never followed, .git never listed, node_modules listed (as ignored: true) but never entered, and at most 20 000 entries walked before truncated: true. /git/file (rev=work), /git/raw, /git/download and /git/upload never needed git for the working copy.
  • /git/download → one working-copy file of ANY type, as application/octet-stream with Content-Disposition: attachment (an ASCII filename= fallback plus filename*=UTF-8''<name>), Content-Security-Policy: default-src 'none'; sandbox, X-Content-Type-Options: nosniff and Cache-Control: no-store — nothing in it renders or runs on the dashboard's origin (server/lib/lane-file-transfer.js). The guards are the file read's own (lanePath, the realpath confinement, isSecretOnDisk; under S3 a member's file is opened through openSafe), at most 512 MB. 403 ESECRET / EFOREIGN, 400 EBADPATH, ENOFILE (also a folder), ETOOBIG.
  • POST /git/upload (same-origin guarded) → 201 {path}. The body is the file's raw bytes (application/octet-stream), its name the URI-encoded X-File-Name header, dir the workspace-relative folder ("" = the root). It only CREATES — a name that is taken (a symlink there included) is 409 EEXISTS, so an upload never replaces a file an agent wrote. It writes into an EXISTING folder only (400 ENOFILE otherwise), never into or under .git (400 EBADPATH); the name must be one plain segment of at most 255 bytes and not .git (400 EBADNAME), never a secret name such as settings.local.json or a member's home dot folder (403 ESECRET); at most 25 MB (413 ETOOBIG); 403 EFOREIGN for a folder linked out of the owner's files. Under S3 the file is written AS the folder's member through write-as (content on stdin); elsewhere with an exclusive create (wx). This is how a member with no SSH to the host moves a file in or out of their own folder.

Read a workspace's own branches

GET /api/lanes/:id/git/branches

{"available": true, "branches": [...], "current": "feat/x", "worktrees": [...]} — the local branches of the workspace's own repo, for the branch switcher. The repo comes from the workspace row, never from the query (that is what GET /api/lanes/branches is for, which takes a candidate path for a workspace that does not exist yet; in server mode with AGENTHUB_USERS_ROOT set, a developer may name only a repo inside their own folder, else 403 EFOLDER).

A session's own worktree (?session=)

Every /api/lanes/:id/git… route above and below takes ?session=<id>: the read or write then works in that session's worktree instead of the workspace's folder. The tree is resolved from the SESSION ROW (server/lib/session-worktree.js): the session must be owned by this workspace (sessions.lane_id), its cwd must not be the workspace's own folder, and git worktree list of the workspace's repo must register it — otherwise 400 ENOWORKTREE, never a fall back to the main folder. With it, GET /git also answers worktree: {path, branch}.

An extra folder's own repo (?source=)

Every /api/lanes/:id/git… route also takes ?source=<id>: the read or write then works in one of the workspace's extra folders (Context → Folder), each its own repo — a section of its own in the Changes panel. The tree is resolved from the lane_sources ROW (sourceFolder in server/lib/lane-sources.js): a folder row of THIS workspace that is still a directory — otherwise 404 ENOSOURCE (another workspace's row and a context-file row included), never a fall back to the main folder. The folder is its own boundary: every guard above (resolvePaths, the secret check, the confirm: true gates, ESUBDIR for a folder below its repo root) applies to it as to a workspace folder, and GET /git reports base as that repo's own default branch. A folder that is not a git repo answers {available: false}.

Remove a session's worktree

POST /api/lanes/:id/session-worktree/remove

Body {"session": "<id>", "confirm": true} (same-origin). Claude Desktop's archive of a worktree session: git worktree remove <path> WITHOUT force (git refuses modified or untracked files: 409 EGIT with git's message), then git branch -d of its branch (-d, never -D: a branch with commits merged nowhere is kept, branchKept: true). A lock held by a live process is 409 ELOCKED; a stale one (the pid in Claude's claude session <name> (pid …) reason is gone) is cleared first. A live pane still holding the session is 409 ELIVE; no confirm is 400 ECONFIRM; a session with no worktree here 400 ENOWORKTREE. Answers {removed: true, branch, branchKept}.

Suggest a commit message

POST /api/lanes/:id/git/commit-message

The Changes panel's ✨ button beside the commit box. Runs the user's own claude CLI — claude -p --safe-mode --no-session-persistence --tools "" --model haiku — on the staged diff of the workspace's cwd (or ?session= worktree) — or, with a body {"paths": [...]} (the panel's ticked files), on those files' working-tree diff against HEAD, untracked ones included — sent on stdin together with the last ten commit subjects so the message follows the repo's style. Only reads: no tool, hook or plugin runs, no transcript is saved, and a settings.local.json is never part of the diff. Same-origin, because each call spends the user's Claude quota. Answers {"message": "..."}; 400 ENOSTAGED with nothing staged (or no readable ticked file), 400 EBADPATH for a path git does not report changed, 400 ENOTOOL without claude on PATH, 502 EAI when the CLI fails, answers empty or runs past 120 s.

Act on a workspace's working copy

POST /api/lanes/:id/git/:action

The git writes the dashboard offers. Guarded (same-origin) and serialized per git index (the workspace lock keyed on the worktree's git dir), so a claude and a codex workspace on one cwd — or a root and a subdirectory workspace 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> — or, with block, of only that hunk's block-th run of +/- lines (the other runs folded into context, as an edited git add -p hunk) {"path": "...", "hunk": "@@ -1,2 +1,3 @@", "block": 0, "context": 3}
discard-hunk git apply -R of that one hunk (or that one block of it), off the working tree {"path": "...", "hunk": "@@ ... @@", "block": 0, "context": 3, "confirm": true}
commit git add -A then git commit -m <message>, or the index as it stands with only: "staged", or — with paths — git commit --only -- <paths>: exactly those files' working-tree content (an untracked one is added first, and taken back out if the commit fails), every other change left as it was {"message": "...", "only": "staged"} / {"message": "...", "paths": ["..."]}
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 -m "Merge branch '<branch>'[ into <current>]" <ref> — a local or remote-tracking branch that exists; the subject is git's own, never the full refname {"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 (or cherry-pick --abort / revert --abort, whichever is open) {"confirm": true}
cherry-pick git cherry-pick <sha> — a non-merge commit, onto a clean repo-root workspace {"sha": "9b3e74a"}
revert git revert --no-edit <sha> — same guards as cherry-pick {"sha": "9b3e74a"}
undo-commit git reset --soft HEAD~1 — only an UNPUSHED, non-merge, non-root HEAD that is still sha; its changes stay staged {"confirm": true, "sha": "<HEAD the user saw>"}
amend git commit --amend -m <message>, or --no-edit with no message — only an UNPUSHED HEAD that is still sha; takes the index as it stands, or with paths exactly those files (--only; an empty list rewords and needs a message) {"message": "...", "sha": "<HEAD>", "paths": ["..."]}
ignore appends one anchored, escaped /<path> line per path to the WORKSPACE's own .gitignore (created if missing, never a line it already has) — untracked paths only (400 ETRACKED), never through a symlink or onto a non-regular file (400 EBADPATH) {"paths": ["..."]}
{ "action": "commit", "ok": true, "output": "[feat/x 9b3e74a] fix: the thing" }
  • commit/amend also take hunks — {"<path>": [{"hunk": "@@ … @@", "blocks": [0]}]} with context — to commit only those change blocks of a tracked, non-renamed file (alone or beside whole paths): the commit is built in a temporary index from git's own fresh diff (never text from the request) and the real index entries of the committed paths are reset to the new HEAD afterwards, the rest left as an unstaged change. A hunk no longer in the diff is 409 EHUNKGONE, an untracked path 400 EUNTRACKED, a malformed entry 400 EBADPATH.
  • commit/amend with paths refuse a conflicted path (409 ECONFLICTS) — a merge is finished by merge-commit — and any path git does not report changed (400 EBADPATH).
  • pull is always --ff-only and switch refuses a dirty tree (400 EDIRTY): neither can invent a merge commit or a conflict underneath a running agent. There is deliberately no rebase, hard reset or stash here — a hard reset stays behind the destroy guard on POST /api/lanes/:id/reset.
  • Cherry-pick and revert follow merge: a commit named by hex sha that exists (400 EBADSHA) and is not a merge commit (400 EMERGECOMMIT), a clean tree, a repo-root workspace. A conflict is a 200 with conflicts[], resolved with resolve and finished by merge-commit (which keeps a pick's original author) or dropped by merge-abort; GET /git then reports merging: true and operation: "cherry-pick"|"revert". A pick or revert whose changes are already there is aborted again and answers 409 EEMPTY, never left open; one git could not commit (no identity, a failing signer) is undone the same way and answers git's own error. A rebase stopped in a terminal makes every history action, merge-commit and merge-abort 409 EMERGING — it is finished where it started.
  • Undo commit and amend rewrite the last commit, so both are 409 EPUSHED once any remote-tracking branch contains HEAD, 409 ESTALE when HEAD is no longer the sha the request names (400 EBADSHA without one — an agent may have committed since the UI drew the commit), and 400 ESUBDIR outside a repo-root workspace. undo-commit needs confirm: true and refuses the first commit (400 ENOPARENT) and a merge commit (400 EMERGECOMMIT); it never touches the working tree. amend with neither a message nor a staged change is 400 ENOCHANGES. All four are 409 EMERGING while a merge, cherry-pick or revert is open.
  • Merge (issue #45) happens only when asked for by name: on a clean tree (400 EDIRTY), from a workspace at the repo root (400 ESUBDIR — a merge is repo-wide), for a branch listed under refs/heads or refs/remotes (400 ENOBRANCH). A merge that stops on conflicts is still a 200, with conflicts: ["path", …]; a clean one answers conflicts: []. While a merge is in progress pull, switch, merge, stage-hunk and discard-hunk are 409 EMERGING, and GET /api/lanes/:id/git reports merging: true plus conflicts (unmerged files, repo-wide). resolve accepts only a path git reports as conflicted (400 EBADPATH) and a side of ours/theirs/done (400 EBADSIDE); done and a plain stage of 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-commit is 409 ECONFLICTS while anything is unmerged, and it and merge-abort are 409 ENOMERGE with no merge in progress. merge-abort needs confirm: true (400 ECONFIRM).
  • push pushes HEAD, never a branch name from the request, so a workspace can only push the branch it is on.
  • A path in the body is never trusted as a path: stage, unstage and discard accept only paths git status is reporting as changed right now, so .., an absolute path and an untouched file are all 400 EBADPATH. A commit without only: "staged" sweeps in (git add -A -- .) only the workspace cwd's own changes, and is 400 ENOCHANGES when the workspace has none — even if the repo is dirty elsewhere.
  • discard and discard-hunk are the irreversible actions here and both require confirm: true (400 ECONFIRM without it). The UI additionally asks twice.
  • A hunk is named by its @@ line and matched against a FRESH diff at the context the caller read it with (send the context the changes read returned), so a hunk the file has moved past is 409 EHUNKGONE, never a stale patch applied somewhere else. The path goes through the same git status check; an untracked file has no hunks (400 EUNTRACKED). A hunk that no longer applies (e.g. part of it is already staged) is 409 EGIT.
  • branch-create never switches — the usual reason to create one from a workspace is to spawn a sibling worktree workspace, and moving this pane's HEAD under a working agent is not. branch-delete is -d, never -D, and refuses main/master/development/develop (400 EPROTECTED), the current branch and a branch another worktree holds (400 EBRANCHBUSY).
  • publish is the one write whose destination comes from the request, so it only ever gives a remote to a repo that has none: 400 ENOCOMMITS before the first commit, 400 EREMOTEEXISTS once any remote exists (it never re-points one), and the name is always origin. The URL is allowlisted — https:// with no userinfo (a token in the URL would land in .git/config), ssh:// and scp-like user@host:path; a local path, file://, ext::, fd::, git://, http:// and anything starting with - are 400 EBADURL. If the push fails, origin is 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 409 EGIT with git's own stderr appended to error.message — a rejection reads better in git's words. Anything else (git not installed, a timeout kill) is 500, still under a string code (EGIT when there is none) — error.code is never git's numeric exit status.
  • In a workspace whose cwd is a subdirectory of its repo, commit refuses with 400 ESTAGEDOUTSIDE while the index holds a change outside that directory: git commit takes 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, not EGIT: the dashboard's git has no terminal to prompt in. The error carries auth: { protocol, host, username, rejected } — rejected: true when a login WAS sent and refused. Resend the same action with credentials: { protocol, host, username, password, remember }: accepted only for a host that is one of the repo's own remotes (400 EBADHOST otherwise), used for that one command, and saved to AgentHub's store (below) only after git succeeded AND remember is true. Empty or multi-line values are 400 EBADCRED.

Your git identity

GET    /api/me/git                 { account: "member"|"host", writable, ssh: { publicKey, fingerprint } | null, https: [{ protocol, host, username, hasToken }] }
POST   /api/me/git/ssh-key         Make the SSH key — Body: { replace? } (409 EEXISTS without replace) → 201, same shape
DELETE /api/me/git/ssh-key         Delete it (404 ENOTFOUND)
POST   /api/me/git/https           Save an https login — Body: { host, username, password }
DELETE /api/me/git/https/:host     Delete one (404 ENOTFOUND)
PUT    /api/me/git/author          Commit author — Body: { name, email } (400 EBADAUTHOR) — `git config --global user.name/email` as the account

GET also answers author: { name, email } (null when unset). A member's commits — the git bar's and their agents' — fail until one is set, so Settings shows a warning and prefills it from Settings → Account. A fetch/pull/push in an ssh-remote repo with no core.sshCommand of its own runs with the account's AgentHub key (default keys still tried), BatchMode=yes and StrictHostKeyChecking=accept-new, so it never waits on a prompt.

The signed-in user's OWN SSH key and HTTPS logins, used by POST /api/lanes/clone (Settings → Git → Your git identity). With a Linux account of their own (S3) everything lives in their home under ~/.config/agenthub/ — ssh/id_ed25519 (+.pub, an unencrypted ed25519 key in OpenSSH format, made by the server) and git-credentials (git's credential-store format) — written only AS the member (write-as, 0600) and read through readFileSafe; no secret crosses sudo's argv or env. Otherwise the account is the host's: the key is <CLAUDE_HOME>/agenthub-ssh/id_ed25519 (dir 0700, file 0600) and HTTPS logins are the shared store below; a non-admin in server mode sees it read-only (403 EHOSTIDENTITY on a write). Only the public key and hasToken are ever returned. Writes are same-origin guarded.

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/git) 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 workspace's runtime

GET /api/lanes/:id/runtime

What is actually running for this workspace, recomputed on every call from pid files and port probes — never cached, because a process can die without telling anyone. See docs/LANES.md#workspace-runtime-running-a-workspaces-own-stack.

{
  "available": true,
  "provisioned": true,
  "slot": 3,
  "kind": "managed",
  "hooks": ["bootstrap", "boot", "health"],
  "profileDir": "/work/myapp/.agenthub/profile",
  "services": [{ "name": "api", "pid": 40213, "alive": true }],
  "ports": { "api": { "port": 8103, "expected": 8003, "listening": true } },
  "database": { "name": "myapp_l3", "testName": "myapp_l3_test" },
  "redisIndex": 3,
  "steppedAside": true,
  "up": true,
  "healthy": true,
  "logs": ["boot.log", "health.log", "api.log"],
  "logDir": "/home/you/.claude/agenthub-lanes/.state/lane3/logs",
  "lastError": null
}
  • database is null when the profile declares no DB_PREFIX; redisIndex is null when REDIS is not 1. Both are names/indices only — never a connection string, so this endpoint never leaks the ~/.agenthub/secrets.env password even though it derives DATABASE_URL internally to run hooks.
  • A workspace whose repository declares no .agenthub/profile returns {"available": false, "searched": [...]} with HTTP 200 — the same contract as GET /:id/git. Most workspaces never run a stack; that is a normal state, not a fault.
  • A workspace with a profile but no slot yet returns {"available": true, "provisioned": false, "hooks": [...], "ports": {}}.
  • expected is PORT_BASE_<name> + slot; when port differs, the allocator stepped aside from a number already in use and steppedAside is true.
  • Not folded into GET /api/lanes on purpose: it opens a socket per declared port and stats every pid file, and the workspace list is polled and re-broadcast on every hook.

Boot or stop a workspace'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 workspace that was never up:

{ "ok": true, "killed": [40213, 40219], "runtime": { "…": "…" } }

Both write only slot and ports on the workspace row. Neither writes stage, status, or notes — in AgentHub those describe the agent's work, not the stack's state. Adopted workspaces 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 workspace with no slot returns 409 ENOSLOT.

Tail a workspace'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 workspace's log directory after realpath, so a name from the request can never escape it; anything else is 404 ENOLOG. A workspace with no slot returns {"available": false}.

Workspace features

Per-feature state and archive (server/lib/lane-features.js) — a workspace's stage/status/etc. is always the LIVE view of whichever feature it's currently on; these routes read and switch between a workspace's feature history.

GET /api/lanes/:id/features

Every feature this workspace 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 workspace'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 workspace; 404 ENOLANE if the workspace itself doesn't exist.

POST /api/lanes/:id/features/activate
{ "slug": "feat/Auth Redesign", "title": "Auth redesign (optional)" }

Switches the workspace to a feature by slug:

  • If a different feature is currently active, it is archived first with its exact live state (stage, status, gate/CI fields, stages, links, notes) — its own title is never overwritten by this.
  • If the target slug was activated before, its saved state is restored onto the live workspace (stage, status, gate/CI fields, stages, links, notes) — this is what makes switching back to a past feature resume where it left off.
  • If the slug is new, a fresh feature row is created (title defaults to the slug itself when omitted).
  • Re-activating the currently active slug is a no-op.

Response: { "lane": <same shape GET /api/lanes/:id returns>, "feature": <same shape as the list above> }.

  • 200 — activated (or already active).
  • 400 { "error": { "code": "EBADSLUG", "message": "slug is required" } } — missing/empty slug, or the canonicalized result is empty (e.g. "feat/" alone).
  • 404 { "error": { "code": "ENOLANE", "message": "workspace not found" } }.

Proof gallery (QC screenshots) captured at <lane.cwd>/.playwright-mcp/proof/<slug>/<phase-group>/ and grouped by feature slug (the same slug from per-feature state). Read-only access and deletion are path-contained: every proof file is resolved, realpath'd, and checked to be inside the workspace's proof root before serving or deleting.

GET /api/lanes/:id/proof

List every feature with proof (or an activated-but-not-yet-shot feature from lane_features), most recently touched first:

{
  "features": [
    {
      "slug": "auth-redesign",
      "groups": {
        "desktop": ["01-login.png", "02-signup.png"],
        "mobile": ["01-login.png"]
      },
      "ticket_report": "auth-redesign/ticket/REPORT.html",
      "mtime": 1722755400
    }
  ]
}

Each feature has:

  • slug (string) — canonicalized feature slug
  • groups (object) — { "phase": ["image1.png", "image2.png"] } — phase-groups with their images
  • ticket_report (string) — relative path to ticket report HTML, or empty if none exists
  • mtime (number) — Unix seconds of the most recent file touched in this feature's proof tree (0 if the feature has no proof yet)

Status codes:

  • 200 — success
  • 404 { "error": { "code": "ENOLANE", "message": "workspace not found" } } — workspace doesn't exist
GET /api/lanes/:id/proof/:slug/:group/:file

Fetch a single proof file (PNG, JPG, or HTML). The file is resolved with path containment checks before serving:

Status codes:

  • 200 — file content, Content-Type set to image/png, image/jpeg, or text/html
  • 404 — workspace, slug, group, or file not found, or path escapes containment
DELETE /api/lanes/:id/proof/:slug

Delete proof at one of three granularities: request body { "group": "phase-name", "images": ["file1.png"] } deletes specific images from a phase, { "group": "phase-name" } deletes an entire phase, or empty {} deletes all phases except ticket/.

{ "deleted": 3 }

Status codes:

  • 200 — success, returns count of deleted files
  • 400 { "error": { "code": "EBADPATH", "message": "…" } } — invalid slug, group, or image name; or attempt to delete ticket/
  • 404 { "error": { "code": "ENOLANE", "message": "workspace not found" } } — workspace 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 workspace proof-link. Idempotent.

Response:

{ "linked": true }
  • linked (boolean) — true if the link was created or fixed, false if the symlink already pointed to the right place or a collision was avoided

Status codes:

  • 200 — success
  • 404 { "error": { "code": "ENOLANE", "message": "workspace not found" } } — workspace 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 workspace moves backwards, so reading it counted one run per workspace row no matter how many features that workspace 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 workspace moves backwards or leaves the template's last node.

Templates configured on a workspace are always listed, at zero, even before that workspace records anything — a pipeline going unused has to be visible as such.

Query parameters:

  • lane — workspace id; narrows the aggregation to that workspace's own runs. Omit (or pass all) for every workspace.
{
  "pipelines": [
    {
      "id": "default",
      "name": "Default feature pipeline",
      "runs": 4,
      "nodes": [
        { "id": "plan", "label": "plan", "icon": "🧭", "gate": false,
          "entered": 4, "passed": 4, "withEvidence": 4, "failed": 0, "detected": 0 }
      ]
    }
  ],
  "lanes": [{ "id": 1, "title": "api lane", "cwd": "/w/api" }]
}
  • detected — runs that reached the node by inference from the hook stream with no declaration. Counted apart from entered: it says the work happened, not that the gate was met. A node the hooks saw is reported as such rather than as "never reached", which is what a session that never runs the pipeline commands used to look like.
  • lanes — every workspace, for the picker. Never narrowed by lane, so the response always offers the way back to the other workspaces.
  • runs — workspaces plus archived features recorded against this template.
  • entered — stage records that resolve onto this node (aliases included).
  • failed — records whose result is "fail"; passed is the rest, so passed + failed === entered.
  • withEvidence — the subset of passes that carry evidence. Tracked apart from passed because a claim and a proof are not the same fact — the same distinction the pipeline map draws between done and passed-no-evidence.

Status codes:

  • 200 — success (an empty pipelines array when no workspace has recorded a stage yet, or when lane names an id that no longer exists)
  • 400 { "error": { "code": "EBADLANE", "message": "lane must be an id" } } — lane is not a positive integer

Workflows

Workflow aggregate bundle

GET /api/workflows?status=active|completed&sections=stats,toolFlow

The Workflow analytics page's eleven sections in one payload: stats, orchestration, toolFlow, effectiveness, patterns, modelDelegation, errorPropagation, concurrency, complexity, compaction, cooccurrence. status scopes every section to sessions of one lifecycle status; omit it (or pass all) for no filter. sections computes and returns only the named ones — Dashboard → Health asks for the seven its cards read. stats.topFlow is the head of toolFlow.transitions (one window pass over the events for both).

The bundle scans every event, so it is computed in a worker thread on its own read-only connection (server/lib/read-pool.js) and never stalls another request, hook or websocket meanwhile. Identical requests in flight share one run, and a result is reused for 30 s per distinct query. AGENTHUB_READ_WORKER=0 computes it inline instead.

Status codes:

  • 200 — the bundle, or the requested sections of it
  • 400 { "error": { "code": "INVALID_SECTIONS", "message": "sections must name some of: …" } } — sections names no known section (unknown names beside known ones are ignored)
  • 500 { "error": { "message": "…" } }
  • 503 { "error": { "code": "EREADTIMEOUT", "message": "read query took over 60000 ms" } } — the run passed AGENTHUB_READ_TIMEOUT_MS (default 60000); its worker is replaced for the next request

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.

Both GET /api/workflows and /skill-usage also take from=YYYY-MM-DD&to=YYYY-MM-DD (+ tz_offset): only the sessions STARTED in those local days. A bad date, or from after to, is 400 INVALID_INPUT.

{
  "skills": [
    { "name": "agenthub:update-project-docs", "plugin": "agenthub", "count": 12, "sessions": 5 },
    { "name": "daily-report", "plugin": null, "count": 3, "sessions": 3 }
  ],
  "plugins": [
    { "name": "agenthub", "skills": 1, "skillCalls": 12, "mcpCalls": 4 }
  ]
}
  • skills — top 30 by call count. plugin is the plugin:skill prefix, or null for a built-in or personal skill.
  • plugins — per-plugin totals: distinct skills used, their calls, and calls to the MCP tools that plugin ships (mcp__plugin_<plugin>_<server>__*), so a plugin used only through MCP does not report zero.

Status codes: 200 always; 500 with { "error": { "message" } } on a query failure.

Locks

Cross-workspace named locks (server/lib/named-lock.js) — the OTHER axis from a workspace's own internal serialization: a name held by one workspace at a time, across ALL workspaces, 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 workspace and sends it explicitly. A holder older than LOCK_MAX_HOLD (default 2700s, floored at 300s — see docs/LANES.md#cross-workspace-named-locks) is broken automatically before the attempt.

  • 200 { "acquired": true } — lock claimed.
  • 409 { "acquired": false, "holder": "lane1", "since": 1722701900, "ageSec": 150 } — already held by someone else (fresh enough not to be broken).
  • 400 { "error": { "code": "EBADHOLDER", "message": "holder is required" } } — missing/empty holder.

Release a lock

POST /api/locks/:name/release
{ "holder": "lane3" }
  • 200 { "ok": true } — released.
  • 409 { "error": { "code": "ENOTHOLDER", "message": "...", "currentHolder": "lane1" } } — holder doesn't match the current owner; a release never trusts its caller, same rule every other destructive path in this project follows.
  • 404 { "error": { "code": "ENOLOCK", "message": "no such lock: build" } } — nothing by that name is held.

Workspace context: instructions, folders, context files, memory

GET    /api/lanes/:id/sources
PUT    /api/lanes/:id/instructions        {"instructions": "…" | null}
POST   /api/lanes/:id/sources/folder      {"path": "/abs/dir"}
POST   /api/lanes/:id/sources/file        raw body, Content-Type: application/octet-stream, X-File-Name: <URI-encoded name>
DELETE /api/lanes/:id/sources/:sourceId
GET    /api/lanes/:id/memory

What a workspace's home page edits (server/lib/lane-sources.js, server/routes/lane-sources.js). GET …/sources and every write answer the same view:

{
  "instructions": "Always answer in Vietnamese.",
  "supported": true,              // the workspace's tool can take them (claude, codex)
  "maxFileBytes": 10485760,
  "maxInstructions": 16000,
  "sources": [
    { "id": 3, "kind": "folder", "path": "/home/u/shared", "name": "shared", "size": null, "added_at": "…", "missing": false },
    { "id": 4, "kind": "file", "path": "<data dir>/workspace-context/12/spec.pdf", "name": "spec.pdf", "size": 20480, "added_at": "…", "missing": false }
  ]
}
  • Instructions — the instructions key is required (400 EBADFIELD without it), a string or null; blank clears; over 16 000 characters is 413 ETOOBIG. The lane is re-broadcast (lane_update). The text is stored in lanes.instructions but is NOT part of the lane payload (GET /api/lanes, lane_update) — read it here.
  • Folder — must be absolute, exist and be a directory (400 EBADPATH), must not be or contain the OS home (400 EBADHOME), and must not be the workspace's own folder or one it already has (400 EDUP). Stored by real path. Removing it only drops the row.
  • File — up to 10 MB (413 ETOOBIG), not empty (400 EEMPTY). AgentHub stores it under <data dir>/workspace-context/<lane id>/ with a name it derives from X-File-Name (directory parts and path characters dropped, name (2).ext on a clash), so a request never names a path. It is a COPY taken at upload: later edits to the original are not seen — remove and upload it again, or add its directory as a Folder (read live from disk). Removing it deletes that copy. At most 50 sources per workspace (400 ETOOMANY).
  • Memory — read-only: the folder's CLAUDE.md, .claude/CLAUDE.md, CLAUDE.local.md, AGENTS.md, and for a Claude workspace <CLAUDE_HOME>/projects/<encoded cwd>/memory/*.md — {files: [{label, path, size, mtime, truncated, content}]}, each at most 256 KB of content.
  • Every write is same-origin only (403 EBADORIGIN); an unknown workspace is 404 ENOLANE, an unknown source 404 ENOSOURCE.

At spawn (spawnRun, both POST /api/lanes/:id/start and POST /api/run) the pane gets, from these rows: Claude — --add-dir <folders…> [<context dir>] first, then --append-system-prompt-file=<data dir>/workspace-prompts/<lane id>.md, a file rewritten at every spawn holding the workspace owner's own instructions (PUT /api/me/instructions) + the workspace's instructions + the context file list + the extra folders; Codex — one --add-dir <dir> per folder (and the context dir) and -c developer_instructions=<same text as a TOML string> (above 6 KB as the escaped TOML string, one line pointing at that file instead; nothing when that file could not be written). Any other tool gets none of it (supported: false). A folder or file that vanished is skipped.

Projects (workspaces grouped by repository)

GET /api/projects
POST /api/projects/refresh

Every workspace grouped under the git repository it belongs to; a workspace home's Add folder suggests the untracked worktrees of its own repo from it. A project's root is the repo's MAIN worktree top level (git rev-parse --git-common-dir, realpath); workspaces outside any repo form the root: null pseudo-project, listed last. worktrees is git worktree list for that repo (a worktree whose directory is gone is left out); workspace_id is the lane sitting exactly on that path, else null — an untracked worktree the UI offers to adopt. A lane on a subdirectory of a worktree is in workspaces but marks no worktree. source is "claude" under <root>/.claude/worktrees/, "agenthub" under LANES_ROOT, else absent.

{
  "projects": [
    {
      "root": "/home/u/agenthub",
      "name": "agenthub",
      "workspaces": [12, 15],
      "worktrees": [
        { "path": "/home/u/agenthub", "branch": "main", "main": true, "workspace_id": 12 },
        { "path": "/home/u/agenthub/.claude/worktrees/wf-1", "branch": "wf-1", "main": false, "workspace_id": null, "source": "claude" }
      ]
    },
    { "root": null, "name": "Folders", "workspaces": [3], "worktrees": [] }
  ]
}

Both git reads are cached in memory — a directory's root for the life of the server, a repo's worktree list for 30 s — and the lane ↔ worktree match is recomputed on every call, so a new or removed workspace needs no invalidation. This route is read on demand (a workspace home's Add folder suggestions), never on the polled GET /api/lanes. POST /api/projects/refresh drops both caches first and answers the same shape; it is same-origin only (403 EBADORIGIN).

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 status value — use awaiting=1
awaiting 1 - Only sessions blocked on the user: awaiting_input_since IS NOT NULL AND status = 'active' (the UI's Waiting filter)
from / to ISO 8601 - Inclusive bounds on started_at; an unparseable value is ignored. Like every filter here, applied before limit/offset, so total stays exact
cwd string - Exact working directory
tool string - One CLI's sessions: claude, codex, antigravity. Also accepted on /api/stats, /api/analytics, /api/workflows (and /workflows/skill-usage) and /api/pricing/cost, where a malformed id is 400 INVALID_INPUT
lane integer - The sessions a workspace OWNS (sessions.lane_id): the ones its panes held. 400 INVALID_INPUT when not an id
archived 0 | 1 - 0 = not archived, 1 = archived only; omitted = both
pinned_first 1 - Pinned sessions first (by pinned_at, oldest pin first), BEFORE limit/offset so a pinned old session stays on the first page, then the normal order. Omitted = order unchanged. The workspace sidebar list (WorkspaceSessionList) sends it
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 lane_id "owning workspace; null if none"
        +string archived_at "null unless archived"
        +string pinned_at "null unless pinned"
        +number cost
        +number agent_count
    }
    
    SessionListResponse --> Session

Update Session

PATCH /api/sessions/:id

Body: any of name, status, ended_at, metadata, archived and pinned. archived (boolean, else 400 INVALID_INPUT) sets or clears archived_at: the session leaves its workspace's lists and nothing is deleted. pinned (boolean, else 400 INVALID_INPUT) sets or clears pinned_at — the session goes to the top of its workspace's sidebar list (?pinned_first=1 below); pinning a pinned session keeps its first pin time, and a pin-only PATCH leaves updated_at where it was (a pin is not activity). Ownership (lane_id) is not patchable: bindRunSession (server/routes/hooks.js) stamps the session a dashboard pane holds with that pane's lane, first writer wins (claimToolSessions in server/lib/dashboard-runs.js does it for a Codex/Antigravity session from the run that was live in its folder). Returns { session }.


Get Session

GET /api/sessions/:id

Returns single session details: {session, agents, events, workflows}. events is every event of the session with its full payload — on a long session hundreds of MB — so ?events=0 answers it as []; the web page uses that and reads its timeline through the paginated events call (the CLI's agenthub session <id> still takes them all).

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 Outline

GET /api/sessions/:id/outline

The session's plan, the files it changed and what it used, read off its successful tool calls (PostToolUse events) and its prompts (UserPromptSubmit) — the activity panel of a Workspace Chat pane. progress follows TaskCreate/TaskUpdate by task id (a TodoWrite replaces the list wholesale); outputs are the paths written or edited with Write/Edit/MultiEdit/NotebookEdit; files the paths it only Read (a file it later changed moves to outputs); uploads the files attached to its prompts through the composer (paths under the <data dir>/drops/ dir — agenthub-drops-<uid> in older transcripts — that POST /api/run/drop-image|drop-file made); memory the Claude Code memory files (.claude/**/memory/) it read or wrote, kept out of the other lists. The path lists are newest first. skills are Skill calls; mcp are MCP server names (mcp__<server>__<tool>, a plugin_ prefix dropped). A file made by a Bash command is not an output here. 404 for an unknown session.

curl http://localhost:4820/api/sessions/sess_abc123/outline
{
  "progress": [{ "id": "1", "subject": "Build the report", "status": "completed" }],
  "outputs": ["/home/me/work/Progress report.xlsx"],
  "files": ["/home/me/work/data/kpi.csv"],
  "uploads": ["/home/me/.claude/agenthub/drops/3f9a0c1d2e4b5a6c-minutes.xlsx"],
  "memory": ["/home/me/.claude/projects/-home-me-work/memory/launch.md"],
  "skills": ["xlsx"],
  "mcp": ["vercel_vercel"]
}

Get an Image the Session Read

GET /api/sessions/:id/image?path=<absolute path>

The bytes of an image the session's own Read tool read (a PostToolUse Read event of this session whose tool_input.file_path equals path exactly) — how a Chat pane's Files read preview shows a screenshot outside the workspace folder. Raster only (png, jpg/jpeg, gif, webp, else 400 ENOTIMAGE), at most 20 MB (ETOOBIG), served with a sandbox CSP, nosniff and no-store. 404 for an unknown session, a path the session never read, or a file that is gone; 403 EUNSUPPORTED for a session pushed from another machine (push:<source>), whose paths are not this host's to read, and in server mode without S3. With S3 a member's session (its folder under the users root) gets only a file inside that member's home that the member owns (else 404), a host session any file — a member's hook can only report sessions in its own home.

curl -o shot.png "http://localhost:4820/api/sessions/sess_abc123/image?path=/home/me/.cache/shot.png"

Get a Text File the Session Read or Wrote

GET /api/sessions/:id/file?path=<absolute path>

{ "text": "...", "truncated": false } for a text file the session's own Read, Write, Edit, MultiEdit or NotebookEdit named (a PostToolUse event of this session whose tool_input.file_path or notebook_path equals path exactly) — how a Chat pane's Outputs/Files preview shows a file outside the workspace folder (a report written to ~/Documents). At most the first 512 KB (truncated: true beyond). 400 EBADPATH for a relative path; 403 ESECRET for a credential by name (isSecretConfigPath — .credentials.json, auth.json, AgentHub's own stores and data dir — or a settings.local.json), judged on the path and on what it resolves to; 415 EBINARY for a file with a NUL byte in its first 8 KB; 404 for an unknown session, a path the session never touched, or a non-regular or missing file. 403 EUNSUPPORTED and the S3 member rules are the same as for /image above.

curl "http://localhost:4820/api/sessions/sess_abc123/file?path=/home/me/Documents/REPORT.md"

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 }
  ],
  "tool_call_count": 5210,
  "distinct_tool_count": 18,
  "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
  }
}

tools_used is the top 15 only, so its sum is not the session's tool-call count. tool_call_count is exact: the session's PreToolUse events with a tool_name, or its PostToolUse events when it has none (an imported session). distinct_tool_count is the number of different tool names.

Error Responses:

Code Description
404 Session not found
500 Internal server error

Get Session Agents

GET /api/sessions/:id/agents

Returns all agents for a session.

Path Parameters:

Parameter Type Description
id string Session ID

Example Request:

curl http://localhost:4820/api/sessions/sess_abc123/agents

Example Response:

{
  "agents": [
    {
      "id": "sess_abc123-main",
      "session_id": "sess_abc123",
      "name": "Main Agent - my-project",
      "type": "main",
      "subagent_type": null,
      "status": "idle",
      "current_tool": null,
      "task": null,
      "started_at": "2024-03-18T12:00:00Z",
      "ended_at": null,
      "updated_at": "2024-03-18T12:05:00Z",
      "parent_agent_id": null,
      "awaiting_input_since": "2024-03-18T12:05:00Z",
      "awaiting_reason": "stop",
      "cost": 0
    }
  ]
}

Note on cost — /api/agents and /api/sessions/:id/agents attach a cost (USD) to each agent: the agent's own cost, computed server-side from the per-agent token buckets stored in agents.metadata.tokens and priced at the current pricing rules (at the agent's start date, so promo/standard cutovers apply — see Pricing). It is 0 for main agents (whose cost is the session total, reported by /api/pricing/cost/:sessionId), for compaction pseudo-agents, and for any subagent whose transcript is unavailable. This lets a subagent card show only what that subagent spent instead of the whole session's total.

Note on status vs Waiting — agents are persisted with one of idle | connected | working | completed | error. The yellow Waiting badge surfaced in the dashboard is a UI overlay derived from awaiting_input_since being non-null on a non-terminal agent (typically idle after a Stop, or connected right after SessionStart). Filter ?status=idle on /api/agents and inspect awaiting_input_since to enumerate currently-waiting main agents.


Agents

List Agents

GET /api/agents

Returns { agents, limit, offset }, newest started_at first, each agent with its own cost (see the note above). Query parameters: limit (default 10000), offset, status, session_id, sources, and:

  • session_ids — comma-separated session ids: every agent of those sessions in ONE call (the Dashboard's active trees). Duplicates and blanks are dropped and at most 200 ids are read; limit still applies, offset does not. An empty list answers { "agents": [] }. When present it takes precedence over session_id, status and sources.
curl "http://localhost:4820/api/agents?session_ids=sess_abc123,sess_def456"

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

Events

List Events

GET /api/events

Returns { events, limit, offset, total }, newest first, filtered by event_type, tool_name, agent_id, session_id (each comma-separated), q, from/to (on created_at) and sources. Each row carries its labels, so a list page need not fetch every session and agent to name them: session_name, session_cwd, session_lane_id, agent_name and agent_subagent_type (each null when the session/agent row is missing). These columns are REST-only — the WebSocket new_event message does not carry them (it carries the row id).


Data cleanup

Clean up old data

POST /api/settings/cleanup

Body { abandon_hours?, purge_days?, dry_run? }. abandon_hours marks active sessions silent that long as abandoned (and completes their working agents); purge_days deletes ended sessions (with their events and agents) that started more than that many days ago. purge_days must be a whole number ≥ 1, else 400 INVALID_PURGE_DAYS — a fraction would purge almost everything. dry_run: true answers the same counts (abandoned, purged_sessions, purged_events, purged_agents, plus dry_run: true) and changes nothing.

Start at boot/login

GET /api/settings/autostart
PUT /api/settings/autostart

Settings → System's Start AgentHub automatically switch. GET answers { available, kind, enabled, foreign }: kind is systemd (a --user unit) or launchd (a LaunchAgent), available is false with no systemd --user / no macOS GUI login, foreign is true when a unit named agenthub exists that AgentHub did not write. PUT { enabled: boolean } (same-origin only) enables or disables the unit — writing one for the running server first when none exists — and answers the same shape. It never starts or stops the server. Off by default: a plugin install gets no unit until this is turned on. 400 EBADVALUE for a non-boolean, 400 EUNAVAILABLE with no service manager, 409 EFOREIGN for a foreign unit.

Status-area icon

GET /api/settings/tray
PUT /api/settings/tray

Settings → System's Show AgentHub icon in the status area switch (server/lib/tray.js). GET answers { available, enabled, running }: available is false in server mode and on a Linux machine with no graphical session (DISPLAY/WAYLAND_DISPLAY, read from systemctl --user show-environment too), running is true while the icon process heartbeats. PUT { enabled: boolean } (same-origin only) removes or writes <data dir>/runtime/tray-off and starts or stops the icon process at once; it never touches the server. On by default. 400 EBADVALUE for a non-boolean.

Quit AgentHub

POST /api/settings/quit

Settings → System's Quit AgentHub and agenthub stop (issue #65; same-origin only, admin-only in server mode). Writes <data dir>/runtime/stopped, kills every agenthub-lane-* tmux session (their runs are recorded killed), answers { ok: true, killed } and then stops the server: through systemctl --user stop --no-block agenthub.service / launchctl bootout when AgentHub's unit is running it (a SIGTERM would be restarted by Restart=always/KeepAlive), else by SIGTERM. While the flag exists the plugin bootstrap starts no server; every server clears it when it starts listening, so a boot/login through an enabled autostart unit brings the dashboard back.

Uninstall AgentHub

POST /api/settings/uninstall

Settings → System's Uninstall AgentHub (same-origin only). Body { "confirm": true } — anything else is 400 ECONFIRM; in server mode 403 ESERVERMODE (a shared dashboard is removed with agenthub uninstall on the host). Spawns scripts/uninstall.js --yes detached and answers 202 { ok: true, log } at once; the worker then stops this server and removes everything agenthub uninstall --yes does (see CLI.md), the database included. log is $TMPDIR/agenthub-uninstall-<uid>.log, the only record left afterwards.


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_event_loop_delay_seconds gauge quantile (0.5, 0.99, 1 = max) Event-loop delay over the last minute — how long requests, hooks and terminal echo waited (a laggy terminal shows here)
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
agenthub_cost_usd_total counter tool, model Cumulative token cost in USD, priced like GET /api/pricing/cost (code-execution container time excluded)

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

A ready-made Prometheus config, recording rules and three Grafana boards built on these series live in monitoring/, runnable with docker-compose.monitoring.yml — see MONITORING.md.


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

Total Cost

GET /api/pricing/cost?tz_offset=<minutes>&sources=<csv>&lane=<id>&tool=<id>&from=<YYYY-MM-DD>&to=<YYYY-MM-DD>&date=<YYYY-MM-DD>

The total cost with a per-model breakdown, plus daily_costs: [{date, cost}] by local date (tz_offset is the browser's getTimezoneOffset()). date=<YYYY-MM-DD> prices only that local day — total_cost, breakdown, by_tool and daily_costs all cover just it (any other shape is 400 INVALID_INPUT); the status bar's Today asks for the local date and shows the day's token total beside it, listing tokens and cost per model on hover (as it does for Workspace and Session). A day is the day the tokens were SPENT: each session's total is split over the local days of its token_usage_hourly rows (one per UTC hour, from each Claude transcript message's timestamp), and whatever the total holds beyond those rows — a session recorded before that table and whose transcript is gone, sub-agent files, Codex and Antigravity sessions — is booked to the session's start date. The days always add up to the total. lane=<id> narrows it to the sessions that workspace owns (sessions.lane_id, or held by one of its dashboard_runs and not stamped yet — the rule of GET /api/sessions?lane=; a non-numeric id is 400 INVALID_INPUT); the status bar shows it as Workspace $… on the Workspace page, beside Session $… from GET /api/pricing/cost/:sessionId for the replayed session or the focused pane's. tool=<id> narrows it to one AI tool's sessions (sessions.tool), and by_tool: [{tool, cost}] (most expensive first) splits the total per tool — each tool priced on its own rows, so the code-execution free allowance applies per tool. A Codex turn on the fast/priority service tier is booked as speed = fast (priced with the model's priority rates, which the pricing sync writes into the fast columns), one on the flex tier as service_tier = flex (half price, like batch). A Claude request whose whole prompt (input + cache read + cache write) is above 100k tokens is also counted into the bucket's long_*_tokens (returned on each breakdown entry) and that part is priced at the rule's long_input_per_mtok / long_output_per_mtok (Haiku 5.5: 5x; cache rates scale by the input ratio; a rule with no long rates prices it at its standard rates).

from / to (local dates, inclusive, read with tz_offset; either end may be left open) narrow total_cost, breakdown, by_tool, feature_costs and unpriced_models to the tokens SPENT on those days (the same split as daily_costs). daily_costs itself stays the whole series, so a 30-day trend or a month-to-date pace is unaffected by the range. A malformed date, a date from year 9999, or from after to is 400 INVALID_INPUT.

curl "http://localhost:4820/api/pricing/cost?tz_offset=-420"
curl "http://localhost:4820/api/pricing/cost?tz_offset=-420&from=2026-09-01&to=2026-09-30"

Get Plan Quota

GET /api/pricing/quota[?force=1]

How much of each native tool's plan quota is spent: { tools: [{ id: "claude"|"codex"|"antigravity"|"cursor"|"grok"|"kimi"|"opencode", active, label?, account?, plan?, expiresAt?, windows: [{ label, usedPercent, resetsAt, group? }], error?, asOf? }], checkedAt } (server/lib/quota.js). Claude asks Anthropic's GET /api/oauth/usage with the access token the CLI holds right now — never refreshed, so an expired token answers error: "ELIVEEXPIRED" until Claude Code refreshes it — cached 5 min (force=1, the status bar's click, re-asks at most every 30 s); no OAuth login, no entry. That live account is active: true; every OTHER saved auth profile follows as active: false with its label, asked with its OWN stored token only while that token is valid — never refreshed here, since a refresh rotates the saved credentials (only the Profiles modal's Check quota does that) — and, once expired, showing the last figure this server read (asOf) or error: "EPROFILEEXPIRED". Each Claude token's owner is also asked (GET /api/oauth/profile): the live row carries the label and email of the account its token belongs to (.claude.json can name another), and a saved profile whose token belongs to a different account answers error: "EWRONGACCOUNT" with no windows, never that other account's usage. Codex reads the newest rate_limits from its rollout journals, no network (asOf = when the journal recorded it; a window past its reset reads 0). Antigravity runs the catalog-detected agy as agy --print /usage --output-format json — the CLI's own /usage command, no model call (num_turns 0, no conversation) — in a private empty temp directory with a 60 s timeout, only while the tool is enabled in AI Tools and agy --version is ≥ 1.1.11 (an older agy may bill /usage as a model request); cached like Claude, a failed run shows the last figure (asOf) or error: "EUPSTREAM". One window per pool bucket (5h, wk), group naming the pool ("Gemini Models", "Claude and GPT models"); the report names no account and no plan. Running agy may let it update itself; AGENTHUB_AGY_USAGE=0 never runs it (no entry). Cursor reads the Cursor CLI's (cursor-agent) own login, $XDG_CONFIG_HOME/cursor/auth.json (default ~/.config), and sends it ONLY to cursor.com's undocumented GET /api/usage-summary as the WorkosCursorSessionToken=<user id>::<token> cookie (user id from ~/.cursor/cli-config.json authInfo), while its JWT is valid and never refreshed — expired or a 401 is error: "ETOKENEXPIRED"; cached like Claude; no file (macOS keeps it in the keychain), no entry. One mo window: individualUsage.plan.totalPercentUsed, resetting at billingCycleEnd (an Enterprise seat without plan shows its plan name and no window). Grok Build reads the CLI's auth.x.ai session in $GROK_HOME/auth.json (default ~/.grok) and sends it only to cli-chat-proxy.grok.com's GET /v1/billing?format=credits (wk = creditUsagePercent, mo = used / monthlyLimit). Kimi Code reads $KIMI_CODE_HOME/credentials/kimi-code.json (default ~/.kimi-code) and sends it only to api.kimi.com's GET /coding/v1/usages (5h/wk/mo from limit_5h/limit_7d/limit_month_total). Both are used only while valid and never refreshed (error: "ETOKENEXPIRED"; a Kimi refresh would rotate the CLI's refresh token and sign it out); no file, no entry. OpenCode Go (id: "opencode", plan: "Go") reads opencode's own opencode-go API key — auth.json, else the credential table of opencode.db, under $XDG_DATA_HOME/opencode (default ~/.local/share) — and sends it only to opencode.ai's GET /zen/go/v1/usage (5h/wk/mo meters); an opencode with no Go key has no entry. These readers follow Orca's (MIT). All three cached like Claude. plan is the subscription's name as the tool reports it — Claude's subscriptionType, Codex's chatgpt_plan_type id-token claim, Cursor's membershipType, Grok's subscriptionTier — shown as a badge beside the account. account is the signed-in email and expiresAt when the stored access token runs out — Claude's claudeAiOauth.expiresAt, Codex's and Cursor's access-token exp (JWT claims read unverified; no token leaves the server but Cursor's own, to cursor.com). Labels: 5h, wk, mo, else <n>d/<n>h (the hover spells wk/mo out as week/month). The status bar shows one "N% used" chip per tool (its live account) at its right end and, on hover, every account grouped per tool with a row per window — bar, percent, reset time. Server mode with S3 (one Linux user per account): a developer or analyst gets ONLY their own account's rows, read from their Linux home at the tools' default paths (~/.claude, ~/.codex, ~/.config/cursor + ~/.cursor, ~/.grok, ~/.kimi-code, ~/.local/share/opencode) — never the host's — and only from files that account owns (open + fstat owner check, a FIFO refused; a member's opencode.db is read from a private copy). No saved Claude profiles and no Antigravity row for them (agy's /usage needs the self-updating binary, so it stays the host's); an account that is not provisioned gets tools: []. An admin gets the host's, as before.

Analytics

GET /api/analytics?tz_offset=<minutes>&sources=<csv>&tool=<id>&from=<YYYY-MM-DD>&to=<YYYY-MM-DD>

The Analytics bundle (tokens, total_cost, tool_usage, daily_events, daily_sessions, agent_types, event_types, avg_events_per_session, total_subagents, overview, agents_by_status, sessions_by_status). With from/to (same rules as above) the shape is unchanged and the numbers narrow: sessions and agents count by started_at, events by created_at, and tokens / total_cost come from the cost rows of the days in the range (token_usage has no date, so tokens follow the spend-day split). daily_events and daily_sessions keep their 365-day window.

The four numbers that scan events (tool_usage, daily_events, event_types, avg_events_per_session) are computed in the read worker (server/lib/read-pool.js) and reused for 10 s per distinct query (scope, range, tz_offset), so they may trail a new event by up to that long. A worker job past AGENTHUB_READ_TIMEOUT_MS answers 503 EREADTIMEOUT.

Spend Budgets

A budget is an alert rule of type budget, created and edited through /api/alerts/rules:

{
  "name": "Monthly",
  "rule_type": "budget",
  "config": { "period": "month", "limit_usd": 200, "thresholds": [80, 100], "tool": "codex", "tz_offset": -420 }
}

period is day | week (from Monday) | month, in the local time of tz_offset (the browser's getTimezoneOffset(), saved with the rule); thresholds are 1–10 whole percents (default [80, 100]), each firing ONCE per period through the alerts feed, webhooks and push; tool / lane narrow it to one AI tool's or one workspace's sessions. Spend is priced like GET /api/pricing/cost, by the day the tokens were spent. cost_threshold ({ "usd": 5 }) is the per-session rule.

GET /api/budgets/status

Every enabled budget with its current period: { budgets: [{ id, name, config, period_start, period_end, spend, projected, pct, crossed }] } — projected carries the pace so far to the period's end, crossed lists the thresholds reached.

POST /api/alerts/rules/preview

{ rule_type, config, days?, cooldown_seconds? } → what the rule WOULD have fired over the last days (1–90, default 7): { supported, window_days, fires, sessions, samples }, and for a budget periods (this one and the five before). Nothing is saved, fired or broadcast; status_duration answers supported: false (agent status history is not stored).


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",
  "sync_codex": true,
  "remote_codex_home": null,
  "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, remote_codex_home, last_error, last_sync_at, and last_sync_counts are nullable. status is one of idle, syncing, ok, error.

sync_codex (default false) also pulls the machine's Codex history: <remote_codex_home>/sessions (default ~/.codex, wsl:~/.codex when remote_home is a wsl: path) and its session_index.jsonl are mirrored with their mtimes kept into <data dir>/remote-sources/<id>/codex/ and read by the same journal reader as local Codex history (server/lib/codex-usage.js), so those sessions arrive as tool = 'codex' rows tagged with this source, with their tokens; last_sync_counts then carries codex_files / codex_sessions. With Codex on, a machine may have only one of the two CLIs: a sync that finds one is ok with the other's failure in last_error; finding neither is an error.

List Remote Sources

GET /api/remote-sources

Returns all configured remote sources. Response: { "sources": RemoteSource[] }.

Create Remote Source

POST /api/remote-sources

Request Body:

Field Type Required Description
label string Yes Human-readable name
host string Yes SSH destination (user@host) or a ~/.ssh/config alias
ssh_port integer No SSH port (defers to SSH default / config when omitted)
identity_file string No Private-key path passed to ssh (-i)
remote_home string No Remote Claude home (defaults to remote ~/.claude)

Cursor (informational): Sessions imported from ~/.claude include Cursor agent usage on that machine too — Cursor happens to use the same paths as Claude Code. AgentHub does not tag which app created a session.

| enabled | boolean | No | Whether the source is eligible for syncs (default true) |

Returns { "source": RemoteSource } with HTTP 201.

Error Responses (400): { "error": { "code", "message" } } with one of:

Code Meaning
INVALID_LABEL Missing/blank label
INVALID_HOST Missing/invalid host
INVALID_PORT ssh_port out of range
INVALID_IDENTITY_FILE Invalid identity_file value
INVALID_REMOTE_HOME Invalid remote_home value

Update Remote Source

PATCH /api/remote-sources/:id

Partial update — only the keys present in the body change. Same fields (and the same validation codes) as create; both label and host are optional here. Returns { "source": RemoteSource }, or 404 if the id is unknown.

Delete Remote Source

DELETE /api/remote-sources/:id

Query Parameters:

Parameter Type Default Description
purge boolean false When true, also delete this source's imported sessions. When omitted/false, those sessions are detached — reassigned to the local source so history is preserved

Returns { "ok": true, "purged": <bool> } (purged is true only when ?purge=true deleted the sessions). 404 if the id is unknown.

Test Remote Source

POST /api/remote-sources/:id/test

Runs an SSH connectivity probe. Returns { "ok": <bool>, "message": <string>, "remoteProjects?": string[] } — remoteProjects lists the discovered remote project directories on success. Does not import anything. 404 if the id is unknown.

Sync Remote Source

POST /api/remote-sources/:id/sync

Pulls Claude Code history from the remote over SSH now, through the same idempotent import pipeline used locally, tagging imported sessions with this source's id. Progress/completion is also broadcast over the WebSocket as remote_source.status frames.

Example Response:

{
  "ok": true,
  "imported": 9,
  "skipped": 41,
  "backfilled": 0,
  "errors": 0,
  "sessions_seen": 50,
  "sessions_tagged": 50
}

404 if the id is unknown; 500 with { error: { code: "SYNC_FAILED", message } } on SSH/import failure.

Sync All Remote Sources

POST /api/remote-sources/sync-all

Pulls history from every enabled source sequentially (one SSH connection at a time). Per-source failures are isolated — one unreachable machine never aborts the others — and each outcome is returned in results. Always 200.

Example Response:

{ "ok": true, "synced": 2, "results": [{ "id": "src_a", "ok": true }, { "id": "src_b", "ok": false, "error": "ssh exited with code 255" }] }

Push ingest (POST /api/hooks/ingest-batch)

For a machine the dashboard cannot reach over SSH: it pushes its own hook payloads. Off until Settings → Database → Push ingest creates a token (POST /api/settings/push-token, returned once; only its SHA-256 is stored as AGENTHUB_PUSH_TOKEN_SHA256).

POST /api/hooks/ingest-batch
Authorization: Bearer ahp_…
Content-Type: application/json

{ "source": "laptop", "events": [ { "hook_type": "SessionStart", "data": { "session_id": "…", "cwd": "/home/me/app" } } ] }
  • 503 EPUSHDISABLED with no token, 401 EUNAUTHORIZED on a wrong one (compared in constant time), 400 EBADSOURCE / EBADBATCH.
  • source: 1–64 of [A-Za-z0-9._-]; the sessions are tagged push:<source> and appear in the sources filter.
  • 1–500 events, 1 MB per request. tool per event is the same allowlist as /api/hooks/event (codex, else Claude Code; an Antigravity hook is answered ignored, since it must name a file under this machine's agy directory).
  • Each event takes the /api/hooks/event path, except: transcript_path fields are dropped (they name the sender's files), no workspace or pane is touched, and a session another source owns is refused per item (EFOREIGN). Privacy settings apply.
  • Response: { ok, source, stored, results: [{ok} | {ok, ignored} | {ok, duplicate} | {ok: false, error}] }. Token cost needs the transcripts, which only an SSH source brings.

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/agents/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 workspace's own project commands and skills are the ones offered. In server mode a non-admin's cwd (on /api/cc-config/* and /api/codex-config/*) must lie inside one of their own workspaces, else 403 EFOLDER. GET /api/cc-config/file and GET /api/codex-config/file never serve a credential file (.credentials.json, auth.json, any non-Markdown name matching the AI Tools secret pattern, anything under <CLAUDE_HOME>/agenthub-*): 400 READ_DENIED.

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 workspace 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 workspaces 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, mismatch } with identity metadata only (name, label, savedAt, active, accountUuid, email, displayName, organization, seatTier, subscriptionType, expiresAt).

Who is logged in is decided by the TOKEN, not by .claude.json. The two can disagree — a running Claude Code session can leave the credentials store holding one account's token while oauthAccount still names another, and every request is then billed to the token's account. So GET /profiles, POST /profiles, POST /profiles/activate and POST /profiles/usage first ask Anthropic who owns the live token (GET https://api.anthropic.com/api/oauth/profile, cc-usage.liveOwner, cached per token — an access token never changes hands). active (and each item's active) is that owner; mismatch is { configEmail, configUuid } when the config names someone else, else null. Offline, or with an expired live token, there is no owner and the config decides, as before.

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 the live token's owner (the config's account only when the owner is unknown) 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. Under a mismatch that snapshot goes to the profile of the token's OWNER and replaces only its credentials (its label and identity stay) — never into the profile the config names, which would hand that profile another account's token — and it is taken even when the owner's profile is the switch target, so the switch writes the newest token that account has. It 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), EMISMATCH (409 — POST /profiles while the live token belongs to another account than the config names; switch to that account's profile, or log in again), 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 running Claude Code session follows a switch by itself: it re-reads the credentials store when the Keychain item's version or .credentials.json's mtime changes (checked about every 30 s in Claude Code 2.1.x), so nobody has to close sessions first. If one still writes its old account back, the token-owner rule above shows the truth and a switch repairs it.

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 page of Settings (/settings/agents/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 workspace. 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`, `codex` or `agy` binary
GET    /api/run/cwds                  Suggested cwds (dashboard, home, recent)
GET    /api/run/history?limit=&laneId=&tool=  Persisted run history; laneId narrows to one lane's runs, tool (with laneId) to one tool's — `shell` = Terminal tabs
GET    /api/run/interrupted           {items} — sessions whose pane died under them (see below), newest per session, max 50
POST   /api/run/interrupted/dismiss   {sessionIds: string[]} (≤ 100) → {dismissed}: stop offering them; resumes nothing. 400 EBADFIELD otherwise
GET    /api/run/files?cwd=&q=&dirs=1  Fuzzy file search inside cwd for the @-file autocomplete (dirs=1 adds folders as `dir/`, for the + menu)
POST   /api/run/drop-image            Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <data dir>/drops/ (0700; S3: the member's ~/.agenthub/handoff/drops/) with a random name, pruned after a day; the terminal pastes the path
POST   /api/run/drop-file?name=&laneId=  Any raw file body (≤20 MB) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image
GET    /api/run/drop/:name            A dropped png/jpg/gif/webp, or txt/md/csv/tsv/log/json/xml/yaml as text/plain, served back (nosniff, sandbox CSP) from the requester's own drop dir (S3: their member account's, owner-checked — for an admin, then the host's, where their host-folder workspaces drop); only a name drop-image/drop-file made → 404 otherwise
GET    /api/run/:id/permission-mode   A live claude pane's mode, read off its mode line → {mode} (null while a menu hides it)
POST   /api/run/:id/permission-mode   {mode} → Shift+Tab steps, each read back, until the pane shows it; 409 EMODEUNKNOWN/EMODEUNAVAILABLE/EBUSY
                                       (skips node_modules, .git, dist, build, .next, .cache, coverage, vendor)
POST   /api/run                       Start/attach run — Body: { laneId, cwd?, model?, permissionMode?, effort?, resumeSessionId?, initialPrompt?, sandbox?, approval?, resumeLast?, bypass? }
GET    /api/run/:id                   Run handle (returns live run state)
DELETE /api/run/:id                   Kill (SIGTERM → SIGKILL after 5 s)

:id must be a workspace 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 PATH for a workspace tool's CLI, looked up in the AI Tools catalog (?tool=<id>, default claude; an unknown id is 400 EBADTOOL). Always a fresh look, not the catalog's cached detection. Returns { found: bool, path: string | null }.

bypass: true (both POST /api/run and POST /api/lanes/:id/start) appends the workspace tool's catalog bypass args/env for that run only; Codex's replaces -s/-a, Claude has none. A workspace whose tool is no longer in the catalog answers 400 ENOTOOL.

POST /api/run (start/attach): Requires laneId (the workspace this run belongs to). Creates or attaches an existing tmux session named agenthub-lane-<id> in the workspace'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 workspace'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 workspace 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 workspace whose run it belongs to) and tool (the CLI that was invoked: "claude" or "codex"). A run's id is its tmux pane name, which each Start in that pane reuses, so the run it replaces is kept as its own row with id = <pane name>@<its started_at> (status abandoned if it never recorded an end). A row still marked running is ended once its tmux session is gone — checked at server start and on the 10 s run-heal beat (DASHBOARD_RUN_HEAL_MS); a pane that survived a server restart stays running. It becomes interrupted when an agent session (not a Terminal tab, session_id known) died under the user — a reboot, a killed tmux, a crash; a Claude session the user ended (SessionEnd with reason prompt_input_exit/logout) and every row with no session become abandoned. Only Claude reports why it ended, so any other tool's vanished pane counts as interrupted. A pane the dashboard closed (Stop, close tab, Quit) is already killed. GET /api/run/interrupted lists the newest interrupted row of each session no newer run holds ({id, session_id, lane_id, tool, cwd, model, permission_mode, effort, prompt_preview, started_at, ended_at, lane_title, lane_cwd, session_name}), only the caller's own workspaces in server mode — the app-wide Resume interrupted sessions? dialog reads it; dismissing flips them to abandoned. The heal beat broadcasts runs_interrupted (no payload) whenever it ended a row, and every client refetches. GET /api/run/history?laneId=<n> returns only that workspace's runs. Spawned Claude processes fire the dashboard's hooks like any other CLI session, so they show up in /api/sessions, and the Dashboard's Analytics tab (Workflow sub-tab included) automatically. Codex runs do not emit these hooks; they are recorded only in run history and dashboard_runs.


AI Tools

GET    /api/ai-tools                  Catalog + detection — { tools: [...], default }
POST   /api/ai-tools/refresh          Re-read the login shell's PATH, re-detect, same body
PUT    /api/ai-tools/:id              Body: { enabled?: boolean, default?: true, defaultModel?: string|null } — same body
GET    /api/ai-tools/:id/models       { flag, models: [{ id, label, hint? }], defaultModel } — the tool's own list
GET    /api/ai-tools/:id/usage        { sessions, cost } — sessions with tool = id, their tokens priced
GET    /api/ai-tools/:id/config       { files: [{ index, path, format: "json"|"jsonc"|"text", exists, size }] }
GET    /api/ai-tools/:id/config/:i    { path, format, text, sha }
PUT    /api/ai-tools/:id/config/:i    Body: { text, baseSha } → { sha, backup }
POST   /api/ai-tools/:id/install      Start the catalog's install job → 202 { job }
POST   /api/ai-tools/:id/update       Start the catalog's update job → 202 { job }
POST   /api/ai-tools/update-all       One job: every npm-managed tool with a newer version + every self updater
GET    /api/ai-tools/job              The current or last job, or null
GET    /api/ai-tools/versions         { [id]: { npmManaged, installed, latest, updateAvailable } }
GET    /api/ai-tools/:id/icon         The tool's favicon, fetched once by the server; 204 → letter avatar

Each tool: { id, label, command, homepageUrl, installed, path, enabled, source: "builtin"|"custom", launcher, prompt: { mode }, resume: bool, bypass: bool, proxyCapable: bool } — flags only, never the arg arrays. Each tool also carries modelFlag (the CLI takes a model: Claude, Codex, or a catalog model: { flag, vendor? } field) and defaultModel (what a new run starts with, null = the CLI's own; a PUT with ""/null clears it, an id that is not a plain model name is 400 EBADMODEL). The config files are only those a built-in or release catalog file lists in config (~/-relative, no .., no credential-looking name); a read or save answers 404 ENOFILE (no such index) / ENOENT (missing — never created), 403 ESECRET (the name, or a symlink target's, looks like a credential), 413 ETOOBIG (> 1 MB), 415 EBINARY, 400 EBADSYNTAX (JSON/JSONC that does not parse), 409 ESTALE (the file is no longer at baseSha); a save backs the old text up under <data dir>/ai-tool-config-backups/<id>/ and is same-origin only. GET /:id/models reads each tool's own source: Codex's $CODEX_HOME/models_cache.json (listed entries by priority — nothing else from that file), Claude's aliases named after the newest priced version plus the priced pinned versions, a catalog tool's vendor-priced ids plus the models its own sessions used; flag: false and [] for a tool with no model flag, 404 ENOTOOL for an unknown id. default is the effective default: the stored one if installed and enabled, else claude if installed and enabled, else the first installed enabled tool, else null. PUT answers 404 ENOTOOL for an unknown id, 400 EBADDEFAULT for a default that is not installed or disabled, 400 EBADFIELD for a non-boolean enabled; disabling the default clears it. Writes are same-origin only. State lives in <data dir>/ai-tools.json. Each tool also carries install ({kind:"npm", package} or {kind:"script", url}, else null) and update ({kind:"npm"}, {kind:"script"}, {kind:"self", args}, else null), and the body carries catalog: { source: "builtin"|"remote", tag, fetchedAt } — remote while a copy of the newest release's catalog, newer than this build, is in use (synced daily, off with AGENTHUB_AI_TOOLS_SYNC=0).

Install / update jobs. Commands come only from built-in or release catalog files, never from the request or DASHBOARD_AI_TOOLS_DIR. npm is the login shell's (npm install -g <package>@latest); a script is downloaded by the server (https, same site as the tool's homepage, ≤ 1 MB) and run as bash <file>; self runs <detected path> ...args. One job server-wide; the job is { id, tool, action, status: "running"|"ok"|"failed", startedAt, endedAt, exitCode, log } (last 256 KB, 10-minute timeout) and lives in memory. A successful install enables the tool. Errors: 404 ENOTOOL; 400 ENOINSTALL/ENOUPDATE/ENOTHING; 409 EINSTALLED/ENOTINSTALLED/ENOTNPM (npm update of a tool whose binary is not under npm prefix -g)/ENONPM/EBUSY. Icons are the root /favicon.ico, raster only by magic bytes (never SVG), ≤ 256 KB, cached in <data dir>/runtime/ai-tool-icons/, served with nosniff and Content-Security-Policy: sandbox; an <img> passes the dashboard token as ?token=. The client shows its own shipped mark instead where it has one (client/public/ai-tools/<id>.svg — claude, codex, openclaw, and hermes as a -black/-white pair swapped by the theme; BUNDLED in client/src/components/AiToolIcon.tsx), so this route is asked only for the other tools.

AI Proxy profiles

GET    /api/ai-proxies                Every saved profile, the built-in `native` one first
GET    /api/ai-proxies/9router        `{ installed }` — is a `9router` binary on the server's PATH (nothing is run)
POST   /api/ai-proxies                Create — Body: { label, baseUrl, apiKey?, model, models?, behavesAs?, scope? }
POST   /api/ai-proxies/test           Probe an endpoint — Body: { baseUrl, model, apiKey?, name? }
PATCH  /api/ai-proxies/:name          Update the same fields, all optional
DELETE /api/ai-proxies/:name          Delete one saved profile

/api/ai-routers (the name before the AI Router → AI Proxy rename) is the same router mounted a second time, kept for a client older than the rename.

A proxy 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 Proxy page of Settings (/settings/ai-proxy; the old /settings/ai-router redirects there), which stores profiles and attaches nothing — the page's Test button (POST /api/ai-proxies/test, below) is its only outbound call. A profile is chosen per NEW SESSION, not per workspace: the AI Proxy select beside the model picker in the workspace home's prompt box (shown only for Claude/Codex and only when a profile beyond Native is saved; the last pick is remembered in agenthub.session.proxy, which falls back to the pre-rename agenthub.session.router).

baseUrl is the proxy's ROOT, without /v1 — e.g. http://localhost:20128. A trailing /v1 is dropped on save, and on read of a profile an older build stored with one. Claude Code gets the root as ANTHROPIC_BASE_URL (it appends /v1/messages); the Codex layer gets root + /v1 (Codex appends /responses).

models carries the five per-tier names (opus, sonnet, haiku, smallFast, subagent). Any tier omitted is filled from model, so a proxy that answers to one symbolic name needs one field, not six.

behavesAs (optional, "" clears it, omitted on PATCH keeps it) is a Claude model id (claude-…, else 400 EBADMODEL) that Claude Code should treat the proxy's own model names as. Without it, a name Claude Code's model catalog does not know — a 9Router combo such as agent — makes every session print "isn't described by this version's model catalog" and compact at an assumed 200k window (issue #56). The user picks it, never AgentHub: it also sets the context window Claude Code compacts at, and only the user knows which model the proxy really sends a name to.

Shared and personal (server mode). Each item carries scope. A "shared" profile (every profile in local mode) lives at the store's top level, and only an admin writes one in server mode (403 EADMIN for a member). A "personal" profile belongs to one user. It is named u<user id>_<slug> (a slug never contains _, so the two kinds never collide), stored under agenthub-proxies/users/<user id>/, and listed only to its owner, admin included. Anyone else who names it in PATCH/DELETE gets 404 ENOTFOUND, and in /test its stored key is never used. On POST, scope is a member's "personal" whatever they send ("shared" → 403 EADMIN), and an admin's "shared" unless they send "personal". GET lists native, the shared profiles, then the caller's personal ones.

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-proxies/ (dir 0700, files 0600); the first read after the rename moves an existing agenthub-routers/ there and leaves a symlink at the old name, so an older build on the same CLAUDE_HOME still finds them. An omitted apiKey on PATCH keeps the stored one; the edit form cannot display a key, so it has none to send back.

GET /api/ai-proxies/9router answers { installed: boolean } from a PATH lookup only (the server's and the login shell's PATH); the AI Proxy page shows a Config 9router link beside Add profile, opening 9router's own UI in a new tab on the dashboard's own host, port 20128 (http://<the host the page was opened on>:20128 — e.g. http://10.1.13.7:20128 from a LAN browser, so the viewer needs no 9router of their own; 9router must listen beyond loopback for that), only when it is true.

POST /api/ai-proxies/test answers the question saving cannot: does this endpoint, with this key, actually serve this model — for each CLI? It sends, in parallel and with a 15 s timeout each, one Anthropic-style message capped at a single output token to <baseUrl>/v1/messages (what Claude Code calls) and one OpenAI Responses call (max_output_tokens: 16) to <baseUrl>/v1/responses (what Codex calls). A proxy that answers is a 200 here whatever it replies: { claude: { ok, status, message }, codex: { ok, status, message } }, each carrying the proxy's own words (truncated to 300 characters), status null when nothing answered at all. A proxy that serves only one wire shape shows one green line and one red. 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 (both compared without /v1) — 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 no model of its own (model: "", models: {} — the session's model picker picks), so choosing it is the same operation as choosing a proxy. 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.

Starting a session on a proxy. POST /api/lanes/:id/start and POST /api/run take proxy: "<name>" (omitted, "" or "native" = the vendor's own API; the pre-rename router field is still accepted). Only the name comes from the request; the key and endpoint come from the store. An unknown name, or another user's personal profile (it resolves only for a workspace its owner owns), is 400 EBADPROXY, a proxy on a tool other than Claude/Codex 400 EUNSUPPORTED. A resume (resumeSessionId, resume) that names no proxy goes back to the one its session last ran on (dashboard_runs.proxy), or native once that profile is deleted or is a personal one the workspace's owner does not own. Each spawn (re)writes the profile's files:

  • Claude gets --settings=<data dir>/proxy-settings/<name>.json (dir 0700, file 0600): an env block with the eight keys 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 (an empty one left out), plus — only when the profile sets behavesAs — modelPicker: { options: [{ model, behavesAs }] } with one row per distinct model name that is not a bare claude-* id. Claude Code reads modelPicker from the policy, flag and user layers and takes the FIRST it finds, so while set it replaces a modelPicker in the user's own ~/.claude/settings.json for that session (verified on 2.1.290). The command-line layer outranks the project-local and user settings, so two sessions of one workspace can run on two proxies, and only the path is in argv. Native adds nothing, so a proxy the user set in their own ~/.claude/settings.json still applies to a native session.
  • Codex gets -p <name> and $CODEX_HOME/<name>.config.toml, the layer that flag loads on top of the base config (never touched). The layer sets base_url to the root + /v1 and wire_api = "responses" (codex-cli 0.149 refuses "chat"); env_key is written only when the profile has a key, and the key reaches the pane as AGENTHUB_PROXY_KEY through tmux's own -e, never as typed text.

One Linux user per account (S3) adds these errors:

  • On the workspace routes:
    • 403 EMEMBERLANE from POST /api/lanes/worktree and from a member folder's /:id/up, /down, /hook/:name, /sync-base, /profile/init, /mcp/sync, DELETE /:id/proof/:slug and POST /:id/proof-link;
    • a git action's typed credential for a member's folder is no longer refused: it goes into the member's OWN store (~/.config/agenthub/git-credentials) through write-as's stdin for that one call and is removed again unless remember was set — 400 EBADHOST for a host that is not one of the repo's remotes, 409 ESTOREUNREADABLE when that store cannot be read (it is then never rewritten);
    • 409 EMEMBERGONE, from a start or a git action in a folder under the users root whose user is disabled or has no Linux account (nothing runs as the host instead);
    • 409 ENOTSHARED, from a start of a catalog tool not yet published to /opt/agenthub/tools;
    • 409 EOWNERFOLDER, from creating (POST /api/lanes, /ensure, /worktree) or starting a workspace inside one member's home that someone else owns, or one outside the users root (it runs as the host) that a non-admin owns, and from POST /api/users/:id/workspaces giving such a workspace to anyone but that member (an admin, for a host folder);
    • 409 ECWDACCOUNT, from a start (POST /api/run, POST /api/lanes/:id/start) whose folder belongs to another Linux account than the workspace's.
  • On POST /api/hooks/event: 403 EPEER (the sender is neither the host nor a member) and 403 EFOREIGN (a member reporting another account's folder or session).
  • On /api/users: 502 EPROVISION (the Linux account could not be created or locked; a disable has already ended the user's logins by then, and a user with no Linux account is disabled without one).

See docs/SERVER-MODE.md.

Server mode: the key gateway. In server mode a pane is never given a profile's key.

  • Claude's ANTHROPIC_BASE_URL and Codex's base_url (+ /v1) point at http://127.0.0.1:<port>/api/proxy-gw/<name>. Neither the settings file nor the layer holds a key.
  • The pane's own AGENTHUB_PANE_TOKEN is its key: AGENTHUB_PROXY_KEY (Codex, in the pane's env) or ANTHROPIC_AUTH_TOKEN (Claude). Claude's goes in the pane's OWN settings file too, <name>--<run id>.json (0600): that layer outranks the user's and project's settings, so their env.ANTHROPIC_* cannot replace it. A gateway pane's file is not rewritten by PATCH (the endpoint and key change behind the gateway at once); a model-list edit reaches it at its next start.
  • ANY /api/proxy-gw/:name/v1/* (server/lib/proxy-gateway.js, mounted before the body parser and the login gate) forwards to <baseUrl>/v1/*. It sends the profile's key as Authorization: Bearer and x-api-key, and streams both ways (SSE included; 10 min idle timeout). The pane's token, cookies and forwarding headers are never passed on.
  • It answers 404 in local mode.
  • 403 ELOCALONLY: not a loopback socket, or a forwarding header is present.
  • 401 EAUTH: the token is not the pane token of a live run (tmux session alive).
  • 403 EPROXY: the run was started on another profile.
  • 400 EBADPATH: the path is not under /v1/, or a .. leaves it.
  • 502 EUPSTREAM: the endpoint is unreachable.

PATCH rewrites the profile's files that exist (a live Claude session reads its file again; the next Codex session gets the new layer) and DELETE removes them, the pre-rename <data dir>/router-settings/<name>.json copy included. Workspaces no longer carry a proxy: POST /api/lanes/:id/router/apply is gone, and at every start the server takes the eight keys an older build wrote into a workspace's .claude/settings.local.json back out and clears the legacy lanes.router_profile column (a file that is not valid JSON is left alone and retried next start).

Pets (the mascot's custom pets)

GET    /api/pets                      { pets, featured, sheet } — your pets, the featured list (with `installed`), the atlas geometry
GET    /api/pets/:id/sprite           A pet's spritesheet (image/webp) or image, raster only, `nosniff`
POST   /api/pets/import               A `.codex-pet.zip` — raw body (application/octet-stream), ≤ 8 MB
POST   /api/pets/upload?name=<file>   One PNG, JPEG, GIF or WebP image — raw body, ≤ 4 MB
POST   /api/pets/featured/:id         Download a FEATURED pet from codex-pets.net into this install
DELETE /api/pets/:id                  Delete a pet (its folder under the data dir)

The mascot (Sagi) can wear a pet: right-click it for Hide pet, Size (48–250 px), Choose pet (Sagi, the featured pets, your own — deletable), Upload your own…, Import .codex-pet bundle…, Find more on codex-pets.net and Pet settings…, which opens the pet block of Settings → Appearance (/settings/appearance#pet, client/src/components/settings/PetSettings.tsx; the old /settings/pet redirects there): show/hide, speech bubbles, size with a live preview, and the same choose / download / upload / import / delete as a grid. The menu and the page share components/Sagi/usePets.ts. Which pet is worn (with its display name, which the mascot goes by) and its size are viewer preferences in localStorage (agenthub.pet, agenthub.pet.size); a name the user types by clicking it in the mascot's panel is agenthub.pet.names ({ [petId]: name }, empty = the pet's own); the pets live on the server, one folder each under <data dir>/pets/<id>/ with a meta.json AgentHub writes.

A Codex pet is the .codex-pet.zip format Codex Desktop uses (the bundles shared on codex-pets.net and in mileson/codex-pets): pet.json (id, displayName, description, spritesheetPath) and spritesheet.webp, a 1536×1872 atlas of 8 columns × 9 rows of 192×208 frames — idle, run right, run left, waving, jumping, failed, waiting, running, review, with 6/8/8/4/5/8/6/6/6 frames — or a v2 atlas (spriteVersionNumber: 2, 1536×2288, 11 rows) whose rows 9–10 add 16 gaze poses, 22.5° apart clockwise from straight up. The mascot plays the row of its mood (an agent working → running, waiting for you → waiting, an error → failed, a finished turn → jumping, a live session at rest → review, idle/asleep → idle), and on an event it plays a moment over the mood (a new session → waving, a finish or a pat → jumping, a new subagent → review, a session waiting on you → waiting, an error → failed); dragged it runs left or right with the drag (and along the glide to its edge), hovered it waves, and a v2 pet at idle turns toward the pointer (back to idle 2 s after the pointer rests). Frames play at Codex's own timings (codex-rs/tui/src/pets/model.rs: e.g. running 120 ms a frame, its last 220 ms); idle plays at the drawn pace (280/110/110/140/140/320 ms), a sleeping pet at Codex's 6× slower one. Waving, jumping, failed and review are moments — they play three times and settle into idle; running, waiting and the run rows loop. Only the two files are read, at the archive root or one folder deep; an entry's size is checked from the zip's directory before it is inflated; the sheet must be a WebP of exactly one of those sizes (400 EBADPET otherwise). An uploaded image is shown still and bobs; SVG is refused because an SVG served from the dashboard's origin runs script.

Featured pets are downloaded, never bundled. The three offered (Goku, Border Collie, 11) are other people's uploads to codex-pets.net with no licence to redistribute them, so AgentHub ships none of their art: POST /api/pets/featured/:id takes only an id from the fixed list in server/lib/pets.js (404 ENOPET for any other, with no request made) and fetches https://codex-pets.net/api/pets/<id> and /spritesheet — no URL from the request, no redirects followed. A pet id is always made by the server (p-<12 hex> for an upload, cp-<featured id>), every write is same-origin (403 EBADORIGIN), and at most 50 pets are kept (409 ETOOMANY). Other errors: 413 ETOOBIG, 502 EFETCH (codex-pets.net unreachable or refusing).

Session snapshots

GET    /api/snapshots           { snapshots: [{ id, token, session_id, created_at, session_name, redacted, event_count, bytes }] } — newest first, no payload
POST   /api/snapshots           { session_id, redact?: { file_paths?: boolean } } → 201 { snapshot: { id, token, session_id, created_at } }
GET    /api/snapshots/:token    { snapshot, payload } — the frozen copy
DELETE /api/snapshots/:id       { ok: true }

A snapshot freezes a session as it is at that moment into one immutable session_snapshots row (server/routes/snapshots.js): payload is { version: 1, captured_at, redacted: { file_paths }, session, agents, events, stats, cost } — the session row, its agents (each with its own cost), every stored event oldest first (events.data exactly as stored), the /api/sessions/:id/stats body and the /api/pricing/cost/:id result priced at the session's start date. Its token is 24 random bytes in base64url (32 characters) and is the only way to read it; the page is /snapshot/<token>. With redact.file_paths, paths are replaced before anything is stored: every cwd, transcript_path, agent_transcript_path, file_path, filePath, notebook_path and path value (also inside events.data and metadata JSON) becomes [redacted], and the session's cwd / the home directory inside any other string (a Bash command, a summary) become <cwd> / ~. A row cannot be updated (a trigger aborts it), outlives its session, and survives Clear Data — it goes only through DELETE (Settings → Snapshots). Both writes are same-origin (403 EBADORIGIN); 400 INVALID_INPUT without session_id, 404 NOT_FOUND for an unknown session, token or id.

Query Explorer

The dashboard's Sessions page reads its Agents tab (/sessions?view=agents; the old /query page redirects there) from entity=agents; sessions and events have their own tabs over GET /api/sessions and GET /api/events. The route still answers all three entities for scripts and exports.

GET /api/query?entity=sessions|events|agents&q=&tool=&status=&cwd=&from=&to=&tz_offset=&sources=&sort=&dir=asc|desc&limit=&offset=&format=csv|json

Filters one table and answers one page — { entity, columns, sortable, statuses, rows, total, limit, offset, export_cap } (limit 1–500, default 50) — or, with format=csv|json, the WHOLE filtered set as a download (Content-Disposition: attachment) capped at export_cap = 50,000 rows; the headers X-Total-Count and X-Truncated: 1|0 say whether it was cut, and the JSON export is { entity, total, truncated, cap, rows }. server/routes/query.js.

Entity Columns sort accepts Date range bounds q searches status
sessions (default) id, name, status, tool, model, cwd, started_at, ended_at started_at (default), ended_at, name, status, tool, model, cwd started_at id, name, cwd active, completed, error, abandoned
events id, session_id, agent_id, event_type, tool_name, summary, tool, cwd, created_at created_at (default), event_type, tool_name, session_id, tool created_at summary, event_type, tool_name, session_id —
agents id, session_id, name, type, subagent_type, status, task, tool, cwd, started_at, ended_at started_at (default), ended_at, name, type, subagent_type, status, tool started_at id, name, task, subagent_type working, waiting, completed, error

tool, cwd and sources apply to the row's session; cwd matches the folder and everything under it. q is a literal substring (% and _ are not wildcards). sort and dir (default desc, ties broken by id) must come from the table above — the SQL column and direction are taken from that allowlist, never from the request — and anything else (an unknown entity/sort/dir/status/format, a malformed tool or date) is 400 INVALID_INPUT. CSV is RFC 4180 (CRLF, a cell with ", , or a line break quoted, " doubled) with a UTF-8 BOM, and a text cell starting with =, +, -, @, a tab or a CR gets a leading ' so a spreadsheet never runs it as a formula.

curl -OJ "http://localhost:4820/api/query?entity=events&q=Bash&from=2026-09-01&format=csv"

Scheduled reports

GET    /api/reports                          { reports: [{ id, name, window_days, frequency, hour, weekday, tz_offset, formats, enabled, last_slot, created_at, last_run }] }
POST   /api/reports                          { name, window_days, frequency, hour, weekday?, tz_offset?, formats, enabled? } → 201 { report }
PUT    /api/reports/:id                      any of those fields (the rest kept) → { report }
DELETE /api/reports/:id                      { ok: true } — the definition, its runs and its files
POST   /api/reports/:id/run                  { run } — run now (also when disabled)
GET    /api/reports/:id/runs                 { runs: [{ id, report_id, created_at, trigger, status, window_from, window_to, stamp, files, error }] } — newest 100
GET    /api/reports/:id/runs/:runId/:format  the file (html | json), as an attachment

A report (server/lib/reports.js) covers the window_days (1–366) full local days BEFORE the day it runs, at its tz_offset (the browser's getTimezoneOffset(), −840…840). frequency is daily or weekly (on weekday, 0 = Sunday, default Monday) at local hour 0–23; formats is a non-empty subset of html, json. Its content is computed like GET /api/analytics and GET /api/pricing/cost over that window — sessions (with status counts), agents, events, tokens, cost by model and by AI tool, unpriced models, the top 10 tools — plus every enabled budget's CURRENT period from GET /api/budgets/status. Each run writes <data dir>/reports/<id>/<stamp>.<format> and a report_runs row (trigger schedule | manual, status ok | error with error); the download path is built from the two ids and the stored stamp only (404 NOT_FOUND for anything else). Every value in the HTML is escaped.

The scheduler (started with the server's background services, every DASHBOARD_REPORTS_MS, default 60000, 0 = off) runs a report when its latest scheduled instant is newer than last_slot, claiming it with one conditional UPDATE so two dashboards on one database never both run it; a slot missed while the server was down runs once at the next start. Creating, editing or re-enabling a report claims the slot already past, so it never runs at once for an hour gone by. Writes are same-origin (403 EBADORIGIN); 400 INVALID_INPUT for a bad field. Definitions and runs survive Clear Data, like alert rules and snapshots.

Daily database backup

GET    /api/settings/backup        { dir, hour, keep, running, last, files: [{ name, size, mtime }], dir_error, env_owned: { dir, hour, keep } }
PUT    /api/settings/backup        { dir?, hour?, keep? } → the same state; dir "" turns backups off
POST   /api/settings/backup/run    202 — started (or already running); poll GET while `running`

Settings → Data → Daily backup (server/lib/db-backup.js). Once a day, at hour (0–23, the server's local time, default 2), the database is copied to dir as agenthub-YYYYMMDD-HHMMSS.db: VACUUM INTO on a read-only connection in a worker thread, so the copy is a consistent snapshot (writes from another dashboard on the same database never restart it), compacted, and the event loop keeps serving while it runs (≈30 s for a 1.25 GB database). It is written as <name>.partial and renamed when complete. Beyond keep (1–365, default 7) the oldest are deleted — only regular files matching that exact name; nothing else in the folder, and no symlink, is ever touched. A day missed while the server was down runs at the next start; a failed run is retried the next day or by Back up now, never every minute. The three values persist to the settings file as AGENTHUB_BACKUP_DIR / AGENTHUB_BACKUP_HOUR / AGENTHUB_BACKUP_KEEP; one the environment set is env_owned (409 EENVOWNED). Writes are same-origin, and admin-only in server mode. 400 INVALID_PATH for a relative, missing, non-directory or unwritable folder, 400 INVALID_INPUT for an hour or count out of range, 400 ENOBACKUPDIR for a run with no folder. The scheduler beat is DASHBOARD_BACKUP_TICK_MS (default 60000, 0 = off).

Restoring is manual, on purpose: agenthub stop, delete dashboard.db-wal and dashboard.db-shm, copy a backup over dashboard.db (its path is on Settings → System), agenthub start.

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 workspace 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 workspace 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 takes the lead whenever the terminal regains focus. Several browsers on one pane share ONE size (server/lib/pty-attach.js): tmux's window-size latest snapped the pane to whoever typed last, so two viewers on screens of different sizes kept resizing it under each other and one saw it cropped. The server names a DRIVER per pane — the first attach, then whichever sends {"type":"resize","cols","rows","drive":true} (the client does on focus and on a keystroke, never on a mouse report). Every other attach is a follower: its PTY is held at the driver's size, a follower's own resize without drive is remembered but not applied, and the server tells it {"type":"size","cols","rows"} — the client resizes xterm to that grid and scales it down with CSS to fit, so the browser in use decides the size and the others just shrink. A follower attaches at the driver's size, so joining never resizes the pane. When the driver's socket closes the rest get {"type":"lead"} and size themselves again. Driver tracking is per server process: a real terminal (agenthub workspace shell) or a second dashboard on the same tmux still follows tmux's own latest rule. 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.