# API Reference
Complete REST API and WebSocket documentation for Agent Dashboard.
---
## Table of Contents
- [Overview](#overview)
- [Authentication](#authentication)
- [Base URL](#base-url)
- [REST API](#rest-api)
- [Sessions](#sessions)
- [Agents](#agents)
- [Events](#events)
- [Data cleanup](#data-cleanup)
- [Tools](#tools)
- [Metrics](#metrics)
- [Pricing](#pricing)
- [Workspaces](#workspaces)
- [Notifications](#notifications)
- [Remote Data Sources](#remote-data-sources)
- [Query Explorer](#query-explorer)
- [Scheduled reports](#scheduled-reports)
- [Webhooks](#webhooks)
- [Web Push](#web-push)
- [Updates](#updates)
- [Import history](#import-history)
- [WebSocket API](#websocket-api)
- [Error Handling](#error-handling)
- [Rate Limiting](#rate-limiting)
- [Pagination](#pagination)
- [Examples](#examples)
---
## Overview
The Agent Dashboard API provides programmatic access to Claude Code session monitoring data.
```mermaid
graph LR
Client[API Client] -->|HTTP/HTTPS| REST[REST API
:4820/api/*]
Client -->|WebSocket| WS[WebSocket
:4820/ws]
REST --> DB[(SQLite)]
WS --> Broadcast[Real-time
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 ` header
- `x-dashboard-token: ` header
- `?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](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` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, displayName?, avatarAt?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). `GET /api/users` also carries each account's [spend quota](SERVER-MODE.md#spend-quotas) — `quotaSetting` (`{mode: "default"|"unlimited"|"custom", limit_usd?, period?, tz_offset?}`) and `quota` (the one binding them now with `spend`, `pct`, `exceeded`, `resetsAt`, or null) — and the top-level `quotaDefault`; an admin sets the default with `PUT /api/users/quota-default` `{quota: {limit_usd, period, tz_offset} | null, currentPassword}` and a user's own with `PATCH /api/users/:id` `{quota}` (`400 EBADQUOTA`). A workspace owner over their quota gets `429 EQUOTA` from `POST /api/run` and `POST /api/lanes/:id/start`. See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups and System Health — the one `/api/users` read EVERY signed-in account may make, a developer included, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `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; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). 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, `409 ENAMETAKEN` for a `displayName` that reads as another account's (the same rule as `PATCH /api/me/account`), `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](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, tokens5h, tokens7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], pins: {claude: [{userId, username, account, pinnedAt}], codex: […]}, blockPercent}` — `pins` are the users an admin pinned to one account (empty in local mode, keyed by tool: `claude`, `codex`, `cursor`); `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool); `cursor: [{name, kind: "apikey", addedAt, livePanes, …}]` is the [Cursor account pool](SERVER-MODE.md#cursor-account-pool) (API keys; `quota` null, spend 0); `groups: [{id, name, createdAt, users: [{userId, username}], accounts: {claude: [names], codex: […], cursor: […]}}]` are the [pool groups](SERVER-MODE.md#account-pool-groups) (empty in local mode). `spend*`/`tokens*` = the sessions the account's runs held, over the hours they held them (a Codex session counts whole once its run started in the window). 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`, or `profile` names a saved Codex profile (`/api/tool-profiles`), MOVED in the same way (the profile file is deleted; the host's own `auth.json` is left as it is). `GET /api/accounts/profiles[?tool=codex]` lists the saved profiles `{profiles: [{name, label, email, subscriptionType, active}]}` (no token; for Claude `subscriptionType` is the readable plan — "Max 5x", "Team Premium"; for Codex the ChatGPT plan or `…1234` of a key). Settings → AI tools → Profiles links each Claude/Codex profile here (`/admin?poolAdd=&poolProfile=` opens the pool's Add dialog on it). Claude rows in `GET /api/accounts` carry `kind` (`token`|`login`), `email`, `plan` (a login's readable plan — "Max 5x", "Team Premium" — else null; Codex rows carry a ChatGPT login's plan) 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. `PUT /api/accounts/pins` `{tool, userId, account, currentPassword}` → `{pin: {userId, username, account}}` switches that user's sessions of `tool` to one pool account (`account: null` = automatic again; `404 ENOUSER`/`ENOACCOUNT`); `switched` says what happened now — `switched` (a Claude login was written into their home: running panes take it at their next credentials read, no restart), `kept`, `own-login` (their own login, left alone), `expired`, `next-session` (a token, Codex, or no member home), with the same password step-up and audit (the user as `owner_id`). `PUT /api/accounts/:name/users` `{tool, userIds, currentPassword}` → `{added: [{userId, username, switched}], removed: [{userId, username}]}` is the Users page's Edit of one account: exactly those users run on it afterwards (a user on another account moves here, one left out goes back to the balancer; one transaction, each change audited; `400 EBADUSER`, `404 ENOUSER`/`ENOACCOUNT`). Removing an account drops its pins and takes it out of every group; deleting a user drops theirs. For Cursor, `token` is a Cursor API key (`tool: "cursor"`, no profile). Pool groups: `POST /api/accounts/groups` `{name, currentPassword}` → `201 {group: {id, name}}` (`409 EEXISTS`, names are case-insensitive); `PATCH /api/accounts/groups/:id` `{name?, userIds?, accounts?: {tool: [names]}, currentPassword}` → `{group}` sets each given part exactly (a user in another group moves here; `404 ENOGROUP`/`ENOUSER`/`ENOACCOUNT`); `DELETE /api/accounts/groups/:id` `{currentPassword}` → `{ok: true}` (its members go back to the shared pool). Same password step-up, admin-only and audit as the other pool changes.
**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: {: ""}, onboardedAt}`; `401 EAUTH` with no login |
| `PUT /api/me/prefs` `{prefs: {: "" \| 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: "" \| 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 |
| `GET /api/me/quota` | server mode: `{quota: {limit_usd, period: "day"\|"5h", tz_offset, source: "default"\|"user", spend, pct, exceeded, resetsAt, since} \| null}` — the account's own [spend quota](SERVER-MODE.md#spend-quotas) now; `null` without one, and always in local mode |
| `GET /api/me/pool-accounts` | server mode: `{tools: [{tool, pin, choice, accounts: [{name, kind, email, expired, quotaPercent, full}]}]}` — per tool (`claude`, `codex`, `cursor`) with at least one account the account's [pool group](SERVER-MODE.md#a-users-own-pool-account) allows; never a token. Local mode: `{tools: []}` |
| `PUT /api/me/pool-accounts` `{tool, account: "" \| null}` | the account's new sessions of `tool` run on `account` (`null` = automatic): `{tool, choice, switched}` — `switched` as for an admin's pin (a Claude login goes into the home at once). Below an admin's pin (`409 ELOCKED`), only an account the group allows (`404 ENOACCOUNT`), `400 EBADTOOL`; login cookie only, audited |
| `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, avatarAt, 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`; `409 ENAMETAKEN` for a display name that reads as another account's display name or username (marks, case, punctuation, bracketed parts like "(you)" and look-alike letters ignored; keeping the name you have is always allowed) |
| `POST /api/me/password` `{currentPassword, newPassword}` | `{ok: true}`; every other login of the account ends, this one stays, and `mustChangePassword` clears. The new password must pass every rule — at least 8 characters, a lower-case and an upper-case letter, a digit, a symbol, not containing the username — and differ from the current one: `400 EWEAKPASSWORD` with `error.rules` (`length`, `lower`, `upper`, `digit`, `symbol`, `username`, `reused`). `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 |
| `GET /api/me/avatar` | server mode: the account's own picture (`nosniff`, `sandbox` CSP; the client adds `?v=`), `404 ENOAVATAR` when it has none |
| `PUT /api/me/avatar` (raw image bytes) | the account's picture (PNG, JPEG, GIF or WebP by magic bytes — never SVG), stored as `/avatars/u.`; the `/api/me/account` shape back with `user.avatarAt` set. `400 EBADIMAGE`, `413 ETOOBIG` over 1 MB, `403 ELOCALMODE`. Login cookie only, audited |
| `POST /api/me/avatar/url` (`{url}`) | the picture as a LINK: stored as typed (`users.avatar_link`, an `https://` URL with no userinfo, ≤ 2048 chars, not `localhost` or a loopback/private/link-local/reserved IP literal) and NEVER downloaded by the server — `GET /api/me/avatar` and `GET /api/users/:id/avatar` answer `302` to it (`Referrer-Policy: no-referrer`), so the viewer's browser loads it with its own logins and network (a Mattermost `/api/v4/users//image` link works). Replaces an uploaded picture, and an upload replaces it (`server/lib/avatar-url.js`). `400 EBADURL`/`EPRIVATEURL`, `403 ELOCALMODE`. Login cookie only, audited |
| `DELETE /api/me/avatar` | removes it (`avatarAt` back to `null`); deleting the user removes it too |
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` (`bypassPermissions` allowed — set only from Settings → AI tools → Session defaults, behind a confirm), `agenthub.codex.sandbox`, `agenthub.codex.approval`, `agenthub.codex.bypass` (`"true"`/`"false"`: a new Codex session's defaults), `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). It also carries what the Dashboard's System Health score reads beyond `/api/host`: `server.event_loop` `{p50, p99, max}` (event-loop delay over the last minute, ms — the same figures as `agenthub_event_loop_delay_seconds` in `/api/metrics`) and `activity` `{last_event_at, working_agents, agents_24h: {completed, error}}` (newest event, agents working now, agents that ended in the last 24 h — scoped to the caller's rows). Pair with `agenthub version` or Settings → System to confirm client and server builds match after deploy.
```mermaid
sequenceDiagram
participant Client
participant API
participant Auth
participant Resource
Client->>API: Request + DASHBOARD_TOKEN
API->>Auth: Validate token (if configured)
Auth-->>API: Valid
API->>Resource: Fetch Data
Resource-->>API: Return Data
API-->>Client: 200 OK + Data
```
---
## Base URL
```
http://localhost:4820
```
For production, use HTTPS:
```
https://dashboard.example.com
```
---
## REST API
### 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
```http
POST /api/lanes/ensure
```
```json
{ "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
```http
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": "" }` 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=`.
A prompt box's `+` menu offers **Choose a file on this computer** (`kind:
"file"`, `start` = the box's folder) to a browser on a loopback host only; the
chosen file is attached as an `@path` mention by its own path — any size,
nothing uploaded. A toast says the dialog is open (it may open behind the
browser); a failure is a toast too, with no in-app fallback.
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/`) 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).
#### File Explorer
```
GET /api/lanes/:id/fs/list?path= { path, parent, entries: [{ name, path, type: dir|file|link|linkdir|other, size, mtime }], truncated }
POST /api/lanes/:id/fs/mkdir { path } → 201 { path }
POST /api/lanes/:id/fs/newfile { path } → 201 { path } (empty file)
POST /api/lanes/:id/fs/rename { path, name } → { path }
POST /api/lanes/:id/fs/move { paths, dest, conflict? } → { moved }
POST /api/lanes/:id/fs/copy { paths, dest, conflict?, fromLane? } → { copied }
POST /api/lanes/:id/fs/delete { paths } → { trashed: [ids] }
GET /api/lanes/:id/fs/trash → { items: [{ id, name, laneId, path, isDir, deletedAt }], keepDays }
POST /api/lanes/:id/fs/trash/restore { id } → { path, laneId }
POST /api/lanes/:id/fs/trash/purge { id | "all" } → { purged }
POST /api/lanes/:id/fs/upload?path=&conflict= raw body, streamed → 201 { path } | 200 { skipped: true }
POST /api/lanes/:id/fs/upload-check { dir?, paths } → { taken: [paths], batch }
POST /api/lanes/:id/fs/upload-batch?dir=&conflict= length + JSON list + bytes → 201 { saved, skipped, paths }
POST /api/lanes/:id/fs/upload-part?upload=&offset=&total= raw bytes (≤ 16 MB) → { received, total }
GET /api/lanes/:id/fs/upload-part?upload= → { received, total, done }
DELETE /api/lanes/:id/fs/upload-part?upload= → { dropped }
GET /api/lanes/:id/fs/zip?p=&p=… application/zip, streamed
```
The workspace **File Explorer** (server mode only — a local dashboard has no **Files** entry, `/files` redirects home, and its buttons are hidden; the routes still answer) — the **Files** page in the sidebar (`/files?lane=&path=`), laid out like GNOME Files (workspaces as places — each nested under its owner's home, dropped down by that home's chevron (open homes kept per browser in `agenthub.files.homesOpen`), an owner whose home the viewer may not open (an analyst's view) under a row that only folds, one with no owner in a flat Workspaces list — Back/Forward, a path bar, icon grid or list, hidden files behind Ctrl+H), and the same manager in a dialog from a workspace's Files tab (the folder button; "Open in File Explorer" on a folder). `?session=` / `?source=` pick the same folder the
git reads do. Paths go through the read path's guards (`lanePath`, the parent
`resolveInLane`-confined, the last segment kept as a name so a symlink is acted
on as a link); nothing in `.git`, no secret file, never the workspace folder
itself (`400 EBADPATH` / `403 ESECRET`). Writes are same-origin and run as the
folder's account (a member's through `write-as`). `conflict` = `fail`
(default: `409 EEXISTS` with `error.names`), `skip`, `keep` (a free
"name (1).ext") or `replace` (the old one goes to the trash first). Delete
moves into the account's trash — a member's `~/.local/share/agenthub/trash/`,
the host's `/trash//` — restorable for 7 days. A copy may
come `fromLane` another workspace the caller can see, of the same account
(`403 EFOREIGN` otherwise). An upload is at most 500 MB (`413 ETOOBIG`, from
`Content-Length` before a byte is stored), a zip at most 2 GB / 50,000 files.
Under S3 a move/copy/delete needs the installed `write-as` to know
`move`/`copy`: `409 EUPGRADE` until `agenthub server install-system` is
re-run.
Many small files cost per FILE (a request, a path walk, under S3 a sudo
`write-as` each), so the Files page sends files up to 4 MB in **batches**
(up to 1,000 files / 32 MB per request; bigger files still go one by one
through `upload`). A batch body is a 4-byte big-endian length, that many
bytes of JSON `{files: [{path, size, mtime?}]}` (paths relative to `?dir=`),
then each file's bytes in that order — at most 5,000 files, 16 MB each,
256 MB in all (`413 ETOOBIG`), `400 EBADBODY` when malformed or short. Every
name passes `upload`'s guards and the whole batch is planned (folders,
`conflict`) before the first write, so a taken name under `conflict=fail` is
ONE `409 EEXISTS` naming them all; `upload-check` asks the same question
before the bytes are sent. A member's batch is written by ONE
`write-as extract` from a tar the server writes itself (folders and
regular files only, `tar -k`: never over an existing file) — `409 EUPGRADE`
(and `batch: false` from `upload-check`) until `agenthub server
install-system` is re-run, and the page then falls back to one request per
file. Measured on loopback (host folder): 1,000 files of 2 KB took 4.3 s one
by one, 0.5 s as one batch.
The Files page sends every upload — a file or a batch — **resumably**
(`server/lib/upload-staging.js`), so a dashboard restart (an update, a manual
`systemctl restart`, a crash) only pauses it. The browser names the upload
with a random id (`upload`, 16–64 of `[A-Za-z0-9_-]`) and sends its bytes in
8 MB parts to `upload-part`, which appends each at `offset` to
`/upload-staging///` on the HOST (never in a member's
home) — `409 EOFFSET` with `error.received` when a part does not start where
the staged bytes end, `409 EBUSY` while another part of the same id is still
arriving, `413 ETOOBIG` past `total` (512 MB at most), `429 ETOOMANY` beyond 8
unfinished uploads per user. Then `upload?…&staged=` or
`upload-batch?…&staged=` writes the staged bytes through the SAME code
and guards as a body would (`409 EINCOMPLETE` before every byte is there); a
commit already made answers its stored answer again (a restart may have
swallowed the first), and a refused one (`EEXISTS`) keeps the bytes for a
retry with another `conflict`. On a network error or a bare 5xx (a proxy
answering for a restarting dashboard) the browser waits up to 3 minutes,
asks `GET upload-part` how much arrived and sends the rest; the dock shows
"The dashboard is restarting…" meanwhile. Uploads are per user — another
user's id is never found — and one untouched for a day is swept. A reload
of the page still loses an upload: the bytes are the browser's.
A file uploaded into a member's folder is written to a hidden
`..agenthub-upload-` by `write-as` and moved onto its name only
once every byte arrived, so a dropped connection or a dying server never
leaves a truncated file under the real name (a `write-as` older than `move`
keeps the old `replace`).
#### File Explorer on a home
```
GET /api/homes { homes: [{ id: "me" | , label, readOnly, dir? }] }
GET /api/homes/:who/fs/list|trash|zip|download as the workspace routes above
POST /api/homes/:who/fs/mkdir|newfile|rename|move|copy|delete|upload|trash/restore|trash/purge
GET /api/homes/trash { items, errors, keepDays } — every Trash the caller may open (below)
```
One line per route, for searching (bodies and answers as the workspace routes above; `copy` takes `fromLane` instead of `fromHome`, `trash/restore` and `trash/purge` also take `store`):
```
GET /api/homes/:who/fs/download?path=
GET /api/homes/:who/fs/zip?p=&p=…
GET /api/homes/:who/fs/trash
POST /api/homes/:who/fs/newfile
POST /api/homes/:who/fs/rename
POST /api/homes/:who/fs/move
POST /api/homes/:who/fs/copy
POST /api/homes/:who/fs/delete
POST /api/homes/:who/fs/trash/restore
POST /api/homes/:who/fs/trash/purge
POST /api/homes/:who/fs/upload?path=&conflict=
POST /api/homes/:who/fs/upload-check
POST /api/homes/:who/fs/upload-batch?dir=&conflict=
POST /api/homes/:who/fs/upload-part?upload=&offset=&total=
GET /api/homes/:who/fs/upload-part?upload=
DELETE /api/homes/:who/fs/upload-part?upload=
```
The same File Explorer on a user's HOME (`server/lib/homes.js`): under S3 their
own Linux home, in server mode without S3 their folder under
`AGENTHUB_USERS_ROOT`, in local mode (or for an admin with no Linux account)
the host account's home. Every dot entry at the TOP of a home (`.ssh`,
`.claude`, `.config`, `.agenthub`, `.bashrc`…) is hidden from listings and
refused for every read and write — also through a symlink, a download or a
zip (`403 ESECRET`); dot files deeper down are ordinary. `:who` is `me`; an
admin may also name another user — read-only (`403 EREADONLY` on every
write), each read written to the audit log. The one exception is that user's
**Trash**.
A home's Trash is its USER's one recycle bin, whatever place they deleted in:
`GET …/trash` merges their home account's trash (a member's
`~/.local/share/agenthub/trash/`) with their host trash
(`/trash//`, what they deleted in a host-folder workspace)
and tags each item `store: "home" | "host"`; `POST …/trash/restore` and
`POST …/trash/purge` take `{id, store}` (`store` defaults to `home`;
`{id: "all"}` empties every store). A host item whose workspace is gone
answers `404 ENOFILE` (nowhere of that account to put it back). An admin may
restore and purge in ANOTHER user's Trash too, each written to the audit log
with the item id — so a member's deleted files can be restored or freed
without waiting the 7 days. `GET /api/homes/trash` merges every Trash the
caller may open into one list (`{items, errors, keepDays}`; an admin's is
every user's, each other user's read audited): each item carries `home` (the
`:who` its restore/purge goes to) and `owner`, and a user whose trash cannot
be read is listed in `errors` rather than left out. The Files sidebar has ONE
**Trash** entry: a user's opens their own Trash; an admin's opens on one
folder per user (**My Trash** first, each counted from that merged list, an
unreadable one showing its error), and opening a folder shows that user's
Trash (path bar **Trash › user**, Up goes back to the folders). A trash that
could not be read shows as an error rather than as empty, and deleting
forever asks first.
A copy goes home ↔ workspace
(`fromHome` on the workspace route, `fromLane` on the home one) when both are
one Linux account; each side is checked under its own rule. A home is never a
workspace. The **Files** page opens on `?home=me` by default.
`me` alone carries `dir`, its absolute folder: the File Explorer's **New →
Clone repository** (`client/src/components/lanes/CloneHereDialog.tsx`) clones
into the folder it has open — of the user's own home or of a workspace's own
folder, never a session worktree, an extra folder or another user's home —
through `POST /api/lanes/clone` below with that folder as `parent`, so every
check of that route applies, and opens a workspace on the clone. It sends
`background: true` and closes as soon as the server accepted the clone; from
then on the app-wide dock (`client/src/components/BackgroundJobsDock.tsx`,
rendered once by `Layout`, store `client/src/lib/cloneJobs.ts` polling
`GET /api/lanes/clone/jobs`) shows the queue place, git's progress, **Stop**
(click twice) and the outcome — on every page, after F5 and after a server
restart (an `interrupted` job offers **Retry**, a login failure an inline login
form, a refused SSH key a link to Settings → Git). Add workspace → **Clone**
and a workspace's **Or clone repositories** box start their clones the same
way. File Explorer uploads show in the same dock (`client/src/lib/uploadJobs.ts`)
with **Stop**; an upload cut short by a reload is shown as interrupted.
#### Clone a repository into a new workspace
```
POST /api/lanes/clone Body: { url, parent, name?, title?, tool?, credentials?: { username, password, remember? }, partial?, progressToken? }
→ 201 { lane, created: true, path }
→ 200 { lane, created: false, path } (the user's own workspace already at that folder + tool, its folder deleted since)
GET /api/lanes/clone/progress/:token
→ 200 { state: queued|running|done|failed, position, running, waiting, limit,
phase: remote|receiving|resolving|checkout|other|null, label, percent, detail }
```
Add workspace → **Clone**. Clones are queued machine-wide
(`server/lib/clone-queue.js`): at most `AGENTHUB_CLONE_CONCURRENCY` run at
once (default half the CPU cores, at least 1), the rest wait in arrival order
with the request held open. Each runs under `nice -n 10` / `ionice -c2 -n7`
with `pack.threads` and `checkout.workers` = its share of the cores (not
persisted; parallel checkout writes a tree of many small files ~4× faster on
4 cores), and is killed
after 30 minutes of running — so several ~1 GB clones no longer starve the
dashboard and the panes. `partial: true` (the form's **Partial clone** box;
`400 EBADFIELD` when not a boolean) makes it blobless (`--filter=blob:none`):
every commit and tree, but a file's contents only when a checkout needs them —
far less to download and unpack for a big repo; checking out an old revision
later fetches from the remote. `progressToken` (16–64 of `[A-Za-z0-9_-]`, unused;
else `400 EBADFIELD`) is the browser's own name for the clone: while the POST is
held open, `GET /api/lanes/clone/progress/:token` answers its 1-based
`position` among the waiting clones, then git's `--progress` line parsed into
`phase`, `percent` and `detail` (`"450/1000, 120.50 MiB | 5.20 MiB/s"`) — the
dashboard's own forms no longer use it (they start background jobs, whose
list carries the same fields, rendered by `CloneProgress.tsx`). Only the user who started
the clone sees it (404 otherwise), and it is dropped a minute after the clone
ends. `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- 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.
#### Background clone jobs
```
POST /api/lanes/clone Body: { …as above, background: true } → 202 { job }
POST /api/lanes/:id/sources/clone Body: { …, background: true } → 202 { job }
GET /api/lanes/clone/jobs → 200 { jobs: CloneJob[] }
DELETE /api/lanes/clone/jobs/:id → 200 { ok: true } (404 ENOTFOUND, 409 EBUSY while queued/running)
POST /api/lanes/clone/jobs/:id/cancel → 200 { job } (404 ENOTFOUND, 409 EDONE once finished)
POST /api/lanes/clone/jobs/:id/retry Body: { credentials? } → 202 { job } (404 ENOTFOUND, 409 EBUSY unless failed/interrupted/cancelled)
CloneJob = { id, kind: "workspace"|"source", state: "queued"|"running"|"done"|"failed"|"interrupted"|"cancelled",
url, name, parent, target, laneId (source: its workspace), position (1-based while queued),
phase, label, percent, detail, error: { code, message, auth? } | null,
result: { laneId, path, created? } | null, createdAt, finishedAt,
request: { url, parent, name, tool?, title?, partial? }, pid }
```
`background: true` (`400 EBADFIELD` when not a boolean) runs every check of
the clone POST synchronously — a refusal still answers 4xx at once — then
answers `202 { job }` and clones in the background
(`server/lib/clone-jobs.js`); without it both routes hold the request open as
above. git runs in a **detached runner** (`scripts/clone-runner.js`: its own
session and process group, the exact command `worktree.git()` would run —
`sudo -n -u ah- git …` for a member — with stderr appended to
`/runtime/clone-jobs//stderr.log` and `exit.json` written when
git ends), so a clone survives a browser reload AND a server restart or
update (the unit is `KillMode=process`; a git whose stderr was a pipe into the
server died of SIGPIPE when it restarted). The server tails the log for
`phase`/`percent`/`detail`, and when the runner ends it creates the workspace
(or adds the extra folder) and broadcasts it exactly as the held-open route,
once; a failure records that route's own `error.code`/`message`
(`ECREDENTIALS` with `auth`, `ESSHAUTH`, `ENOREPO`, `EEXISTS`, `ECLONE`…). Jobs
live in `/runtime/clone-jobs.json` (0600, tmp + rename; progress at
most every 2 s, every state change at once). On start the server **adopts** a
job whose runner is still alive (counted against `AGENTHUB_CLONE_CONCURRENCY`,
tailed, finished when it exits), finishes one whose `exit.json` is already
there, requeues a queued one, and marks the rest `interrupted`
(`EINTERRUPTED`, "AgentHub restarted while this clone ran") — a queued job
that carried a typed login is interrupted too, so the login is typed again.
`cancel` takes a queued job out of the queue or SIGTERMs the runner's process
group (git removes its half-made folder; sudo relays the signal to a member's
git); the job ends `cancelled` (`ECANCELLED`) and never makes its workspace or
extra folder. `retry` starts a NEW job from the old one's `request` through
every check of a fresh POST, then drops the old one. `request` never holds a
credential: a typed `credentials` login is used for that one run (the host's
travels in the runner's environment, as it would in git's) — a host
`remember` is saved only when the same server process sees the clone succeed.
Done jobs are kept 24 h, failed/interrupted/cancelled ones 7 days. A job is
only ever looked up among the caller's own (`scope.ownerFor`; local mode:
every job), 404 otherwise. The browser polls; there is no websocket message.
#### Update a workspace
```http
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 `). 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
```http
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`.
```json
{
"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 `` (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
```http
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:
```json
{
"prompt": "Write a test for the user endpoint",
"model": "claude-opus-5",
"permissionMode": "plan",
"effort": "medium",
"resumeSessionId": "session-xyz",
"sandbox": "read-only",
"approval": "on-request"
}
```
All fields are optional.
- **`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 `.
- **`approval`** — one of `on-request` or `never`, or omitted/empty for no approval prompt. Passed to `codex -a `.
- **`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 `. 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 --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: ""`** — 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--` (the `=` form, since `-w [name]` would take the prompt as the name; the name is made by the server), so Claude itself creates `/.claude/worktrees/` on branch `worktree-`, 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-` and is what `run_id` records; slots 1–9 are extra sessions on the same workspace, `agenthub-lane--`, 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 `--resume`s 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 `/run-prompts/.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.
#### Other workspace actions
```http
POST /api/lanes/:id/:action
```
The one control route behind `start` (above), `stop`, `message`, `clear`, `reset`, `remove` and `purge` (same-origin). Any other `:action` is `400 EBADACTION`, an unknown workspace `404 ENOLANE`. `reset`, `remove` and `purge` are destructive and need `{ "confirm": true }` (`400 ECONFIRM`) — see below.
#### Preflight a destructive workspace action
```http
GET /api/lanes/:id/preflight?action=reset|remove|purge
```
Returns the exact facts a user must confirm. `reset` and `remove` return
`head`, `dirty`, `untracked`, `unpushed`, `database`, `blocked`, and `warnings`;
`purge` returns `sessions`, `events`, `tokenRows`,
`bytesEstimate`, and `activeSessionSkipped`. `expect` is required: reset/remove
must include all of `head`, `dirty`, `untracked`, and `unpushed` (not `database`
— see below); purge must
include `sessions`, `events`, and `tokenRows`. Missing or incomplete
confirmation facts return `400 EEXPECT`.
`database` is the name a `reset` (unless `keepDb: true`) or `remove` will drop
— `null` when the 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
```http
POST /api/lanes/:id/reset
POST /api/lanes/:id/remove
POST /api/lanes/:id/purge
```
All three actions require `{ "confirm": true }` and a complete `expect` object,
and run under the 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`.
```json
{
"confirm": true,
"force": true,
"keepDb": false,
"expect": {
"head": "9b3e74a",
"dirty": 4,
"untracked": 11,
"unpushed": 2
}
}
```
For `purge`, `expect` contains `sessions`, `events`, and `tokenRows` from its
preflight. If any echoed fact changed, the action returns `409 ESTALE` with its
`expected` and `current` diagnostics, without resetting, removing, or purging.
Missing `force` for unpushed work returns `409 EUNPUSHED`.
`reset` restores the managed worktree's feature branch from its base, cleans
untracked files but preserves ignored files, clears the 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:
```json
{
"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
```http
GET /api/lanes/:id/git
```
What the workspace's directory looks like to git right now:
```json
{
"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
```http
GET /api/lanes/:id/git/changes?range=work|branch|commit[&sha=][&path=][&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=` | 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 `...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 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.
```json
{
"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.
- A row longer than **4,000** characters (an inlined base64 image or video, a
minified bundle) comes back cut to its first 1,000 with `elided: `, BEFORE the per-file cap is applied, so one huge line no longer eats
the hunks after it. Each file's raw patch is read up to 16 M characters; past
that it is still `truncated`.
- 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.
#### History: commit details, patch, file history, blame, browse, compare
```http
GET /api/lanes/:id/git/commit?sha=
GET /api/lanes/:id/git/patch?sha=
GET /api/lanes/:id/git/file-history?path=[&skip=][&limit=]
GET /api/lanes/:id/git/blame-at?path=[&rev=work|]
GET /api/lanes/:id/git/tree-at?rev=[&path=]
GET /api/lanes/:id/git/compare?a=&b=[&mode=three|two][&path=[&from=]]
GET /api/lanes/:id/git/pick-plan?op=cherry-pick|revert&shas=,
GET /api/lanes/:id/git/sequence
```
The History tab's reads (`server/lib/git-history.js`, mounted by
`server/routes/lane-git-history.js`). Read-only and unguarded like every
`/git` read, each resolving its repo exactly like them (the lane row, or
`?session=` / `?source=` / `?repo=`); `{available:false}` with HTTP **200**
for a folder git cannot read. A revision is a sha or a ref name (no leading
`-`, no `..`; `~n`/`^n` suffixes allowed) and is resolved to a commit before
git sees it, else `400 EBADREV`. A path is lane-relative with no `..`
(`400 EBADPATH`). `403 ESECRET` / `EFOREIGN` as for `/git/file`.
- **`/git/commit`** → `{sha, short, subject, message, author, email, date,
committer, committerEmail, committedAt, parents, badges, isHead, branches,
tags, containsTruncated}` — the details pane; `branches`/`tags` contain the
commit (50 each). Its files are `/git/changes?range=commit&sha=`.
- **`/git/patch`** → `{sha, patch, omitted, truncated}` — `git format-patch -1
--stdout --relative` over the commit's files inside the workspace (Copy
patch). A secret file's diff is NEVER in it: it is named in `omitted`.
2 MB cap. A merge commit is `400 EMERGECOMMIT`.
- **`/git/file-history`** → `{path, commits:[{sha, short, subject, author,
date, parents, status, path, from?}], hasMore}` — `git log --follow`: each
commit with the name the file had THERE (`from` = a rename's old name), so
a version's diff is `/git/changes?range=commit&sha=&path=`.
- **`/git/blame-at`** → `{path, rev, commits: {: {short, author, date,
summary, previous?}}, lines: [{sha|null, text}]}` — blame at a commit (or
`work`), with each line's text; `previous` is the commit to blame next
("before this change"). 1 MB cap (`400 ETOOBIG`), `400 ENOFILE`.
- **`/git/tree-at`** → `{rev, path, entries:[{path, name, dir, submodule,
size}], truncated}` — one folder at a commit (Browse files), folders first,
5000 entries; a secret file is left out of the list, and its content stays
refused by `/git/file?rev=`.
- **`/git/compare`** → `{a, b, mode, onlyA, onlyB, commitsTruncated, files,
filesTruncated}` — the commits only in `a` / only in `b`
(`--left-right a...b`, 300 each) and the files that differ: `mode=three`
(default) is `a...b`, what `b` adds since they diverged; `mode=two` is
`a b`, the trees as they stand. With `path` (and a rename's `from`):
`{a, b, mode, file}` with that file's hunks (200 KB cap); a secret file is
`{secret: true, hunks: []}`.
- **`/git/pick-plan`** → `{op, commits, branch, clean, repoRoot, busy}` — what
`cherry-pick`/`revert` with `shas` would run, in the order it would run it
(a pick oldest first, a revert newest first; ancestry, then date), for the
confirmation dialog. `400 EBADSHA` / `EMERGECOMMIT`.
- **`/git/sequence`** → `{active:false}` or `{active:true, operation, current,
remaining, done, startedFrom, conflicts}` — the cherry-pick/revert that is
paused (the banner's "paused at X (2 of 5)"); a merge is not reported here.
#### Read a workspace's commits, a file, or the file tree
```http
GET /api/lanes/:id/git/log?limit=30[&skip=][&all=1][&ref=][&q=][&author=][&path=]
GET /api/lanes/:id/git/blame?path=
GET /api/lanes/:id/git/file?path=[&rev=work|HEAD|]
PUT /api/lanes/:id/git/file {path, content, baseSha}
GET /api/lanes/:id/git/raw?path=[&rev=work|HEAD|]
GET /api/lanes/:id/git/ls?q=
GET /api/lanes/:id/git/tree?path=
GET /api/lanes/:id/git/tree-all
GET /api/lanes/:id/git/download?path=
POST /api/lanes/:id/git/upload?dir= (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, email,
date, refs, parents, badges}], hasMore, filtered}`, newest first in
`--date-order` (no parent before its children; `parents` = parent shas,
first parent first) — the History tab's commit graph. `badges` are the refs
pointing at the commit, typed for drawing: `{kind: "local"|"remote"|"tag"|"head",
name, current?}` (`current` = the branch HEAD is on, `head` = a detached
HEAD; `refs/remotes/*/HEAD` left out). `limit` is clamped to 500 (default
30) and `skip` pages it; `hasMore` says another page exists. `all=1` reads
every branch, remote branch and tag (`--exclude=refs/stash --all`). Search:
`q=` matches the message case-insensitively and LITERALLY (never a regex),
and a hex `q` also puts the commit with that SHA prefix first; `author=`
matches name or e-mail, literally; `path=` (alias `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`. `q`/`author` over
200 characters are `400 EBADQUERY`; a walk is killed after 20 s. Reading
one commit's diff is `/git/changes?range=commit&sha=`.
- **`/git/blame`** → `{available, path, commits: {: {short, author,
date, summary}}, lines: [|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 `
`; 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 :./` (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](SERVER-MODE.md#what-the-second-review-closed)) 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: {"": [{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''`),
`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
```http
GET /api/lanes/:id/git/branches
```
`{"available": true, "branches": [...], "remotes": ["origin/feat/y"], "current": "feat/x", "worktrees": [...]}`
— the local branches of the workspace's own repo, for the branch switcher, and
`remotes`: the fetched remote-tracking branches that have no local branch of
that name yet (the remotes' `HEAD` left out). The switcher lists them under
"On the remote" and re-reads the list after every fetch and pull. 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`).
#### Read every branch and tag (the Branches panel)
```http
GET /api/lanes/:id/git/refs
```
`{"available": true, "current": "main", "head": "", "local": [...], "remotes": [{"remote": "origin", "branches": [...]}], "tags": [...], "truncated": false}`
— what the git bar's **Branches & tags** panel (`client/src/components/lanes/git/BranchManager.tsx`)
lists, read by `server/lib/git-refs.js`. Each `local` entry carries `name`,
`sha`, `current`, `upstream` (or null), `ahead`/`behind` against it (null
without one; `gone: true` when the upstream ref is gone), the last commit's
`subject` and `date`, `merged` (into HEAD — what `git branch -d` checks),
`worktree` (another worktree holding it) and `protected`. `remotes` groups the
fetched remote-tracking branches by remote (symbolic `HEAD`s left out). Each
tag carries `name`, its target commit `sha`, `annotated`, and for an
annotated tag its `message`, `tagger` and `date`. At most 1000 of each kind
(`truncated`). Same target rules as every git read (`?session=`, `?source=`,
`?repo=`); a folder that is not a repo answers `available: false` with 200.
#### A session's own worktree (`?session=`)
Every `/api/lanes/:id/git…` route above and below takes `?session=`: 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=`: 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}`.
#### Repos inside a parent workspace folder (`?repo=`)
A workspace whose folder holds several repos side by side
(`~/work/shop/{api,web,apps/admin}`) lists them with:
```http
GET /api/lanes/:id/repos[?refresh=1]
→ { "root": false, "items": [{ "path": "api", "name": "api" }, …], "truncated": false }
```
`server/lib/nested-repos.js` walks the disk only (no git call): every directory
up to 2 levels below the workspace folder holding a `.git` entry (a directory,
or a submodule's / linked worktree's file), never through a symlink, a dot
folder or `node_modules`/`vendor`/`dist`/`build`/`target`, never inside a repo
it already found; at most 50 repos and 5000 directory entries. Cached 30 s per
folder; `?refresh=1` reads again. `root` is whether the workspace folder
itself is in a repo. The Changes panel calls it only for an account that
turned on Settings → Git → Changes panel (`agenthub.changes.innerRepos` =
`all`; off by default).
Every `/api/lanes/:id/git…` and `/fs…` route also takes `?repo=` (the
`path` above): it is accepted only when that walk found it — re-walked at most
every 2 s for a name the cache lacks — otherwise `404 ENOREPO`, never a fall
back to the workspace folder. Like an extra folder, the repo is its own
boundary under every guard above, and `GET /git` reports its own default
branch as `base`. The Changes panel shows one section per repo (the workspace
folder's own section only when it is itself a repo).
#### Remove a session's worktree
```http
POST /api/lanes/:id/session-worktree/remove
```
Body `{"session": "", "confirm": true}` (same-origin). Claude Desktop's
archive of a worktree session: `git worktree remove ` 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 (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
```http
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.
#### Conflicts, reflog, remotes and rewrite plans
```http
GET /api/lanes/:id/git/conflict?path=
GET /api/lanes/:id/git/reflog?ref=HEAD|&skip=0&limit=50
GET /api/lanes/:id/git/remotes
GET /api/lanes/:id/git/rewrite-plan?kind=rebase&onto=
GET /api/lanes/:id/git/rewrite-plan?kind=interactive[&base=]
GET /api/lanes/:id/git/rewrite-plan?kind=reset&target=
GET /api/lanes/:id/git/rewrite-plan?kind=push
```
Reads (no same-origin guard), all taking `?session=`/`?source=`/`?repo=` like
the other git routes (`server/lib/git-conflicts.js`, `server/lib/git-rewrite.js`).
- **`/git/conflict`** — the 3-way merge editor's input for a path
`git ls-files -u` lists as unmerged right now (else `400 EBADPATH`), with
`/git/file`'s guards (`403 ESECRET`, 1 MB cap): `{operation, branch,
incoming: {sha, short, subject}|null, base, ours, theirs, working, sha,
usable, binary, tooBig}`. Each side is read by its blob id (stages 1/2/3); a
side git does not have is `null`. `sha` is the `baseSha` `conflict-save`
needs. In a rebase `ours` is the branch being rebased onto and `theirs` the
commit being replayed.
- **`/git/reflog`** — `git log -g` of HEAD or of a local branch that exists
(`400 EBADBRANCH`/`ENOBRANCH`), newest first: `{ref, more, entries:
[{index, selector, sha, short, action, message, time, subject}]}`.
- **`/git/remotes`** — `{remotes: [{name, url, pushUrl, defaultBranch,
branches}]}`; an http(s) URL loses its whole userinfo and any other keeps only
the user name, so no password or token is ever answered.
- **`/git/rewrite-plan`** — what a rewrite would do, for the dialog that asks
before it: every answer has `head` (the sha a write must name), `branch`,
`subdir`, `operation`, `dirty`; `rebase` adds `commits[]` (each with
`pushed`), `behind`, `pushed`; `interactive` adds `base`, `merges`,
`commits[]` (with full `message`), `pushed`; `reset` adds `target`,
`related`, `dropped`, `added`, `pushed`; `push` adds `remote`,
`remoteBranch`, `remoteSha` (the lease), `ahead`, `behind`, `protected`,
`allowed`. A bad `kind` is `400 EBADMODE`.
#### Act on a workspace's working copy
```http
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 HEAD` — the branch's upstream remote, else the only remote, else `origin`, else `400 ENOREMOTE` | — |
| `stage` | `git add -- ` | `{"paths": ["..."]}` |
| `unstage` | `git restore --staged -- ` (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); with `hunks` (a partly ticked file's ticked change blocks, named as in `commit`), `git apply -R` of only those blocks off the working tree first — every patch built before any is applied, so a stale one is `409 EHUNKGONE` with nothing undone | `{"paths": ["..."], "confirm": true}` / `{"paths": [], "hunks": {"": [{"hunk": "@@ … @@", "blocks": [0]}]}, "context": 3, "confirm": true}` |
| `stage-hunk` | `git apply --cached` of ONE hunk of `git diff -U HEAD -- ` — 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 `, or the index as it stands with `only: "staged"`, or — with `paths` — `git commit --only -- `: 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] `; a branch only a remote has (`origin/feat/y`, or bare `feat/y` when exactly one remote has it — else `400 EAMBIGUOUS`) becomes `git switch -c feat/y --track refs/remotes/origin/feat/y`, the remote ref looked up among the fetched ones. A dirty tree needs `changes`: `carry` (a plain `git switch` that brings the changes along) or `shelve` (shelve them, then switch) | `{"branch": "...", "create": false, "changes": "carry\|shelve", "note": "..."}` |
| `branch-create` | `git branch []` — or, with `switch: true`, `git switch -c []`, the uncommitted changes coming along. With `from`, the start point is LOOKED UP (a local or remote branch — a remote one becomes the upstream —, a tag, or a hex sha verified as a commit) and `switch: true` then runs `git switch ` on a clean tree only | `{"branch": "...", "base": "...", "switch": true}` / `{"branch": "...", "from": "v1.2.0", "switch": true}` |
| `branch-delete` | `git branch -d ` | `{"branch": "..."}` |
| `branch-rename` | `git branch -m ` — the current branch too | `{"branch": "...", "name": "..."}` |
| `branch-upstream` | `git branch --set-upstream-to=refs/remotes/ `, or `--unset-upstream` with no `upstream` | `{"branch": "...", "upstream": "origin/x"}` / `{"branch": "..."}` |
| `tag-create` | `git tag `, or `git tag -a -m ` when a message is given — on HEAD or on a looked-up `from` | `{"tag": "v1.2.0", "message": "...", "from": "..."}` |
| `tag-delete` | `git tag -d ` — this machine only | `{"tag": "...", "confirm": true}` |
| `tag-push` | `git push refs/tags/:refs/tags/` — ONE tag, never `--tags`, never forced | `{"tag": "..."}` |
| `publish` | `git remote add origin -- ` 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 ''[ into ]" [` — 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 ` — a non-merge commit, onto a clean repo-root workspace | `{"sha": "9b3e74a"}` |
| `revert` | `git revert --no-edit ` — same guards as `cherry-pick` | `{"sha": "9b3e74a"}` |
| `cherry-pick` / `revert` with `shas` | ONE git sequence of 1–50 non-merge commits, in `/git/pick-plan`'s order (`server/lib/git-pick.js`) | `{"shas": ["9b3e74a", "c1d2e3f"]}` |
| `pick-continue` | `git cherry-pick --continue` / `git revert --continue` once nothing is unmerged | `{}` |
| `pick-skip` | `--skip` the commit the pick/revert stopped at (drops its resolutions) | `{"confirm": true}` |
| `pick-abort` | `--abort`: back to where the pick/revert started | `{"confirm": true}` |
| `undo-pick` | `git reset --soft ` after a pick/revert — its changes stay staged | `{"from": "", "to": "", "confirm": true}` |
| `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": ""}` |
| `amend` | `git commit --amend -m `, 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": "", "paths": ["..."]}` |
| `shelve` | `git stash push --include-untracked -m "agenthub-shelf: · [ · ]" [-- ]` — everything, or exactly the ticked changed `paths` | `{"note": "...", "paths": ["..."]}` |
| `unshelve` | `git stash apply --index ` (retried without `--index` when only the index cannot be rebuilt), then `git stash drop` of that entry when it applied cleanly | `{"sha": ""}` |
| `shelf-drop` | `git stash drop` of that entry | `{"sha": "", "confirm": true}` |
| `ignore` | appends one anchored, escaped `/` 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": ["..."]}` |
| `conflict-save` | the 3-way merge editor's result written over the conflicted file through `PUT /git/file`'s guards (`baseSha` → `409 ESTALE`, `403 ESECRET`), then `git add`; a result with a marker left is `400 EMARKERS` before anything is written, unless `resolve: false` (save progress, still conflicted) | `{"path": "...", "content": "...", "baseSha": "", "resolve": true}` |
| `rebase` | `git rebase ][` (no editor, no autostash, no other branch moved) — a local or remote-tracking branch that exists | `{"onto": "main", "head": "", "force": false}` |
| `rebase-continue` / `rebase-skip` | `git rebase --continue` (`409 ECONFLICTS` while anything is unmerged) / `--skip` | — |
| `rebase-abort` | `git rebase --abort` | `{"confirm": true}` |
| `rebase-interactive` | `git rebase -i ` with a todo AgentHub builds from the list — no editor opens | `{"head": "", "base": "", "todo": [{"sha": "", "action": "pick\|reword\|squash\|fixup\|drop", "message": "..."}], "force": false, "confirm": true}` |
| `reset` | `git reset --soft\|--mixed\|--keep\|--hard ` | `{"target": "", "mode": "keep", "head": "", "confirm": true, "force": false, "confirmBranch": ""}` |
| `undo-rewrite` | `git reset --keep ` — puts the branch back where a rewrite found it | `{"to": "", "head": "", "confirm": true}` |
| `force-push` | `git push -u --force-with-lease=refs/heads/: HEAD:refs/heads/` — `` is the current branch's upstream branch | `{"expect": "", "confirm": true}` |
| `remote-branch-delete` | `git push --delete refs/heads/` — a fetched remote-tracking branch | `{"branch": "origin/feature", "confirm": true}` |
| `remote-add` / `remote-rename` / `remote-remove` | `git remote add -- ` / `rename -- ` / `remove -- ` | `{"name": "upstream", "url": "git@host:o/r.git"}` / `{"name": "...", "to": "..."}` / `{"name": "...", "confirm": true}` |
```json
{ "action": "commit", "ok": true, "output": "[feat/x 9b3e74a] fix: the thing" }
```
- `commit`/`amend` also take `hunks` — `{"": [{"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`) unless `changes` says what happens to the changes: neither
can invent a merge commit or a conflict underneath a running agent. A
rebase, a reset or a force push happens only as its own action (below).
- **Switch with changes.** `changes: "carry"` runs a plain `git switch`
(never `--force`, `--merge` or `--discard-changes`); when git refuses
because a carried file would be overwritten the answer is
`409 ECARRYCONFLICT` with `files: ["…"]` (git's own list), and nothing has
moved. `changes: "shelve"` shelves the changes (as `shelve` below, with an
optional `note`) and then switches; if the switch fails the shelf is
restored at once and git's error is returned. Every check that needs no
switch attempt (`ENOBRANCH`, `EBRANCHBUSY`, …) runs before the shelf is
made. Any other `changes` value is `400 EBADCHANGES`. A successful switch
answers `shelves: [...]` — the TARGET branch's shelves, which the UI offers
back (asking, never applying one itself) — and `shelved: {...}` when it
made one. `branch-create` with `switch: true` is `409 EMERGING` while a
merge, cherry-pick, revert or rebase is open, and `409 ECARRYCONFLICT` if a
`base` other than HEAD clashes with the changes.
- **Shelves** are git stash entries AgentHub made, recognised by the message
prefix `agenthub-shelf: ` — any other stash is the user's and is never
listed or touched. They are listed by `GET /api/lanes/:id/git/shelves`
(`{available, shelves: [{ref, sha, branch, note, createdAt, files: [{path,
status}]}]}`, newest first; untracked files as `??`) and addressed in
writes by `sha`, the stash commit, never by `stash@{n}` (a terminal stash
would shift it); an abbreviation works, a sha no shelf has is
`409 ENOSHELF`. A stash is repo-wide, so `shelve`, `unshelve` and
`shelf-drop` need a workspace at the repo root (`400 ESUBDIR`), and
`shelve`/`unshelve` are `409 EMERGING` during a merge, cherry-pick, revert
or rebase. `shelve` on a clean tree is `400 ENOCHANGES`; its `paths` go
through the same `git status` check as `discard`. `unshelve` refuses a
dirty tree (`400 EDIRTY`) so a restore never mixes with new work; a clean
apply drops the entry (`kept: false`), a conflicted one KEEPS it and
answers `conflicts: [...]`, `kept: true` — the files are resolved with
`resolve` (`ours` = the branch, `theirs` = the shelf; there is no merge to
commit afterwards, the result is an uncommitted change) and the shelf is
deleted by hand. `shelf-drop` is irreversible and needs `confirm: true`
(`400 ECONFIRM`). Nothing else ever drops a shelf.
- **A working agent.** When an agent of the workspace is working (a lane no
pane holds: `status: running` from its hooks; a lane a pane holds: one of
its own sessions has a main agent `working`), `shelve`, `unshelve` and a
`switch` that carries or shelves uncommitted changes answer
`409 EAGENTRUNNING` until resent with `confirm: true` — they move files
under it.
- **History rewrites** (`server/lib/git-rewrite.js`) are allowed because each
one's recovery is a button too. Every one runs only from a repo-root
workspace (`400 ESUBDIR`), never while a merge, cherry-pick, revert or rebase
is open (`409 EMERGING`), and names the full `head` sha the plan showed
(`409 ESTALE` once HEAD moved, `400 EBADSHA` without it). Rewriting a commit
any remote-tracking branch already holds is `409 EPUSHED` unless the body
says `force: true` (the UI shows that a force-push will be needed). Each
answers `before`/`after`; `undo-rewrite` (`reset --keep `, only while
HEAD is still `after`) undoes it.
- `rebase` and `rebase-interactive` need a clean tree (`400 EDIRTY`). A stop
is a **200** with `conflicts[]` and `stopped: true`; while a rebase is open
`GET /git` reports `merging: true, operation: "rebase"`, every write except
`fetch`, `stage`, `unstage`, `discard`, `ignore`, `resolve`,
`conflict-save` and the rebase's own steps is `409 EMERGING`, and the
banner's `merge-commit` / `merge-abort` act as `rebase-continue` /
`rebase-abort` — a rebase started in a terminal included.
`rebase-continue`/`-skip`/`-abort` with no rebase open are `409 ENOREBASE`.
- `rebase-interactive`: `todo` lists EXACTLY the commits of `base..HEAD`
(default base: the parent of the oldest commit no remote has) by full sha,
each once, in the new order, else `400 EBADTODO`; a merge commit in the
range is `400 EMERGECOMMIT`; a list that changes nothing is
`400 ENOCHANGES`; a reword needs a message (`400 EBADMESSAGE`). AgentHub
writes the todo and the messages as 0600 files in the repo's own git dir
(as the folder's account), hands git `cp` as its sequence editor and `true`
as its editor; a squash becomes `fixup` + `exec git commit --amend -F `
with the combined (or given) message, so no request text is ever shell.
- `reset`: `target` must be an ancestor or descendant of HEAD
(`400 ENOTRELATED`), `mode` one of soft/mixed/keep/hard (`400 EBADMODE`);
`hard` only on a clean tree (`400 EDIRTY`) and with `confirmBranch` equal to
the current branch name (`400 ECONFIRM`). `keep` refuses (git's own `409`)
rather than overwrite a local change.
- `force-push`: HEAD only, to its upstream branch, with a lease on `expect` —
`409 ESTALE` when the remote moved since the fetch the plan read;
`main`/`master`/`development`/`develop` are `400 EPROTECTED` unless the
server runs with `AGENTHUB_GIT_FORCE_PUSH_PROTECTED=1`; a detached HEAD is
`400 EDETACHED`.
- `remote-branch-delete` refuses a protected name (`400 EPROTECTED`) and the
remote's default branch (`refs/remotes//HEAD`, `400 EDEFAULTBRANCH`);
a branch not fetched is `400 ENOBRANCH`.
- `remote-add` takes its URL through `publish`'s allowlist (`400 EBADURL`)
and a name of letters, digits, `.`, `_`, `-` (`400 EBADREMOTE`); a taken
name is `400 EREMOTEEXISTS`; `remote-remove` needs `confirm: true`.
A stored git credential still goes only to its own host.
- **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 `409 EMERGING`;
`merge-commit`/`merge-abort` then continue/abort that rebase.
- **Several commits at once** (`shas` instead of `sha`, `server/lib/git-pick.js`)
run as ONE git sequence — `git cherry-pick A B C`, `git revert --no-edit C B A`
— in the order `GET /git/pick-plan` shows (a pick oldest first, a revert
newest first), under the same guards (1–50 commits, none a merge, a clean
repo-root tree, nothing already open — `409 EMERGING`). The answer is
`{from, to, commits, conflicts, paused, applied}`: `paused: true` with
`conflicts[]` stopped at a conflict, with `empty: true` at a commit whose
changes are already here. A paused one shows on `GET /git/sequence` and is
finished by `pick-continue` (`409 ECONFLICTS` while anything is unmerged,
`409 EEMPTY` when the resolution changed nothing — skip it then),
`pick-skip` or `pick-abort` (both `confirm: true`, `400 ECONFIRM`; abort
puts the branch back at `from`), `409 ENOMERGE` when nothing is open.
`undo-pick {from, to, confirm: true}` takes a finished pick/revert back with
`reset --soft ` (the changes stay staged): only while HEAD is still
`to` (`409 ESTALE`), `from` is its ancestor 1–50 commits back with no merge
between (`400 EBADSHA` / `EMERGECOMMIT`), and none of those commits is on a
remote-tracking branch (`409 EPUSHED`). The single-`sha` body is unchanged.
While a rebase is open every one of these is `409 EMERGING`, and
`GET /git/sequence` does not report a rebase as a paused pick.
- **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`).
- Any action on a host with no `git` binary (or none on the dashboard's PATH)
is `503 EGITMISSING`: a missing dependency, not a bad request; the message
carries the install command (like `ETMUXMISSING` for tmux).
- `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 unless `switch: true` — 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`).
- **Branch & tag tools** (`server/lib/git-refs.js`, the Branches panel): every
ref the body names is LOOKED UP — under `refs/heads`, `refs/remotes` or
`refs/tags`, or a hex sha `rev-parse --verify ^{commit}` confirms — and git
is handed the full refname or sha it found, never the request's text
(`400 EBADREF`; `400 EAMBIGUOUS` when a branch and a tag share the name —
spell out `refs/tags/x`). New names go through `git check-ref-format`
(`400 EBADBRANCH` / `400 EBADTAG`; a tag may not start with `-`).
`branch-rename`, `branch-upstream` and the three tag actions are repo-wide,
so they run only from a repo-root workspace (`400 ESUBDIR`) and never while a
merge, cherry-pick, revert or rebase is open (`409 EMERGING`; so is
`branch-create` with `switch: true`). `branch-rename` refuses the protected
names (`400 EPROTECTED`) and an existing name (`400 EBRANCHEXISTS`);
`branch-upstream` takes only a fetched remote branch (`400 ENOBRANCH`) and
unsetting one that has none is `400 ENOUPSTREAM`; `tag-create` refuses an
existing tag (`400 ETAGEXISTS`) and needs a commit (`400 ENOCOMMITS`);
`tag-delete` is irreversible and needs `confirm: true` (`400 ECONFIRM`);
`tag-delete`/`tag-push` of a missing tag is `400 ENOTAG`. `tag-push` goes to
`origin`, else the only remote (`400 ENOREMOTE`), with the same `EGITAUTH`
login flow as `push`; a remote tag already at another commit is a `409 EGIT`
in git's words. Deleting a REMOTE tag is not offered.
- `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`, `EBADCHANGES`); 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: { type, publicKey, fingerprint, terminal: "on"|"off"|"foreign" } | null (the first key), sshKeys: [{ id, type, name, publicKey, fingerprint }] (default ed25519, default rsa, then named keys by name; `name` null for a default key, `id` = its file name), sshTerminal, sshUsers: [{ host, user, alias, key }], https: [{ protocol, host, username, hasToken }], author: { name, email }, defaultEmail }
POST /api/me/git/ssh-key Make an SSH key — Body: { replace?, type?: "ed25519" | "rsa", name? } — without `name` the DEFAULT key of that type (`id_ed25519`/`id_rsa`), with `name` (lowercase letters, digits, `-`, ≤ 32; 400 EBADKEYNAME) one more beside it, `id__` — for another AWS account or a second GitHub user. 409 EEXISTS when that key exists without replace, or the name is taken by a key of the other type; 409 ETOOMANYKEYS past 10 keys → 201, same shape
DELETE /api/me/git/ssh-key Delete one key — `?id=` (400 EBADKEYNAME for a malformed id), or the default key of `?type=ed25519|rsa` — or every key without either (404 ENOTFOUND, 400 EBADKEYTYPE). A login naming a deleted key goes back to every key; our terminal `core.sshCommand` follows the keys and goes with the last one
POST /api/me/git/ssh-key/import Import a private key the user already has — Body: { privateKey, name?, sign?, replace? } — OpenSSH (`BEGIN OPENSSH PRIVATE KEY`) or PEM (PKCS#1/PKCS#8) text, ed25519 or RSA ≥ 2048; saved as a NAMED key (`name`, else `signing`, `signing-2`…) written like a generated one, so it serves clones too; only `imported` (its public half) comes back. 400 EBADKEY (not a key, a `.pub`, halves that do not match), EKEYPASSPHRASE (an encrypted key — a dashboard commit has nowhere to type the passphrase), EBADKEYTYPE; 409 EEXISTS (the same key already imported, or the name taken), ETOOMANYKEYS. With `sign: true` commits are signed with it at once; a refusal of that step comes back as `signError` (the key stays saved) → 201
PUT /api/me/git/signing Sign commits — Body: { key: | null, replace? } — writes `gpg.format ssh`, `user.signingkey ` (ssh-keygen signs with it; a `.pub` would need an ssh-agent) and `commit.gpgsign true` to the account's `~/.gitconfig`, so terminal, dashboard and agent commits are all signed; `null` removes the three only when the key is ours. 409 EFOREIGNSIGN over a signing key the user set themselves, unless `replace: true`; deleting the signing key stops signing. `GET` answers `signing: { state: "on"|"off"|"foreign", key, path }` (`path` = the user's own key file when foreign)
PUT /api/me/git/ssh-terminal Use the keys for git typed in the account's own terminal — Body: { on } — sets/unsets `core.sshCommand` in its `~/.gitconfig` (every AgentHub key first, then `~/.ssh/id_*`; rewritten as keys are made or deleted while on); on by default when a member makes a key; 409 EFOREIGNSSH over one the user wrote, which is never touched
PUT /api/me/git/ssh-user An SSH login — Body: { host, user, alias?, key?, allRegions? } — `allRegions: true` (a CodeCommit host, no alias — else 400 EBADHOST) writes the rule for EVERY CodeCommit region at once (the list is `codecommitRegions` in `GET /api/me/git`), replacing each region's own; `DELETE /api/me/git/ssh-user/?allRegions=1&user=` removes them all. Otherwise it writes `url."ssh://@/".insteadOf "ssh:///"` to the account's `~/.gitconfig`, `` = `alias` or the host itself (replacing ours for that host/alias), so `ssh:///…` logs in as `user` (AWS CodeCommit: the IAM SSH key ID `APKA…`). `alias` (lowercase letters, digits, `-`, NO dot — 400 EBADALIAS) is one more account on the same host: two AWS accounts clone as `ssh://aws-prod/v1/repos/x` and `ssh://aws-dev/v1/repos/x`. `key` (a key id, 400 ENOKEY) is stored as `agenthub..sshKey` there: a clone of a `ssh:///…` URL then offers ONLY that key (`IdentitiesOnly`) and persists it into the clone's config; without it every key is offered. An IAM SSH key ID logs in to every region of its AWS account, so a Clone of `ssh://git-codecommit..amazonaws.com/…` tries every CodeCommit login saved — the region's own first, then other regions' and every named key's (another AWS account), each with its own key: the first the remote accepts (`git ls-remote`), or the only one, and the clone's `origin` then names that user. So a second account's key clones from a plain AWS URL too, with no `ssh:///` rewrite. 400 EBADHOST / EBADSSHUSER
DELETE /api/me/git/ssh-user/:from Drop the rule of that host or alias and its key link (404 ENOTFOUND); any other `insteadOf` the user wrote is never touched
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. A new SSH key is labelled with that email, else `defaultEmail` (`@smartgift.vn`; `AGENTHUB_EMAIL_DOMAIN` overrides the domain), also returned by GET /api/me/git as the email form's suggestion
```
`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` or `ssh/id_rsa` (+`.pub`, unencrypted, made by the server: ed25519 in OpenSSH format by default, or RSA 4096 as PKCS#1 PEM for hosts that take only RSA, such as AWS IAM/CodeCommit; one key per account, and `ssh.type` says which) 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
`/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 `/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=` 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
```http
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`.
```json
{
"available": true,
"provisioned": true,
"slot": 3,
"kind": "managed",
"hooks": ["bootstrap", "boot", "health"],
"profileDir": "/work/myapp/.agenthub/profile",
"services": [{ "name": "api", "pid": 40213, "alive": true }],
"ports": { "api": { "port": 8103, "expected": 8003, "listening": true } },
"database": { "name": "myapp_l3", "testName": "myapp_l3_test" },
"redisIndex": 3,
"steppedAside": true,
"up": true,
"healthy": true,
"logs": ["boot.log", "health.log", "api.log"],
"logDir": "/home/you/.claude/agenthub-lanes/.state/lane3/logs",
"lastError": null
}
```
- `database` is `null` when the profile declares no `DB_PREFIX`; `redisIndex`
is `null` when `REDIS` is not `1`. Both are names/indices only — never a
connection string, so this endpoint never leaks the `~/.agenthub/secrets.env`
password even though it derives `DATABASE_URL` internally to run hooks.
- A 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_ + 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
```http
POST /api/lanes/:id/up { "build": true }
POST /api/lanes/:id/down
```
`up` returns **202** and boots in the background, because a build can run for
minutes. Progress streams as `lane_hook_output` WebSocket messages and the
attempt finishes with a `lane_runtime` message carrying the fresh facts (or an
`error`). `build: false` passes `--no-build` to the `boot` hook.
`up` runs the profile's `boot` then `health` hooks; it does **not** run
`bootstrap`. A failing `health` leaves the processes running so their logs
remain readable, and records `EUNHEALTHY` in the runtime's `lastError`.
`down` is synchronous, idempotent, and a no-op for a workspace that was never up:
```json
{ "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
```http
POST /api/lanes/:id/hook/:name { "args": ["--scope", "smoke"] }
```
Runs one of the profile's hooks — the surface a driving session uses for
`ci-gate`, `e2e`, `migrate` and friends. Returns **202**; output streams as
`lane_hook_output` and completion arrives as `lane_hook_result` with the exit
`code`.
`:name` is checked against a fixed allowlist (`bootstrap`, `boot`, `health`,
`migrate`, `seed`, `ci-gate`, `e2e`, `regen`, `db-create`, `db-drop`) **before**
anything is spawned, and `args` travels as an array of strings straight into
argv — neither is ever joined into a command string. An unknown name returns
`400 ENOHOOK` with the allowed list; a workspace with no slot returns `409 ENOSLOT`.
#### Scaffold a profile, sync MCP servers, read an integration
```http
POST /api/lanes/:id/profile/init
POST /api/lanes/:id/mcp/sync
GET /api/lanes/:id/integrations/:name
```
`profile/init` (same-origin) detects a Node.js project in the workspace's OWN folder and scaffolds `.agenthub/profile/` there — the HTTP twin of `agenthub workspace profile init`. `{ force?: true }` overwrites; answers `{ scaffolded: true, written, todos }`, or `{ scaffolded: false, reason }` (200) when no Node.js project is found; `400 EPROFILEEXISTS` without `force`. `mcp/sync` (same-origin) copies the source repo's `mcpServers` from `~/.claude.json` into `/.mcp.json` (paths relocated) and seeds Playwright profiles → `{ servers, profilesSeeded }`; `400 ENOMCPCONFIG` when the source has none. Never automatic — a session calls it. `integrations/:name` → `{ enabled }`, read from `.agenthub/profile/integrations.env`. All three `404 ENOLANE` for an unknown workspace; under S3 the two writes refuse a member's folder the way the runtime routes do.
#### CI status and base sync
```http
GET /api/lanes/:id/ci/status?ref=
POST /api/lanes/:id/sync-base
```
`ci/status` is ONE snapshot of GitHub Actions for `ref` through `gh` in the workspace's folder — not a poll loop: `{ state: "pending"|"success"|"failure", ref, checks: [{ name, conclusion, url }] }`; `400 EBADREF` without `ref`. `sync-base` (same-origin, under the workspace lock, needs the workspace profile) merges the profile's base branch INTO the workspace's branch behind a migration-number collision preflight: body `{ mode?: "check"|"merge"|"continue" (default merge), branch? }`. Every documented outcome — clean, a collision, a conflict left in place — is a 200 with `code` 0/4/5; only a malformed request, a missing profile or an out-of-order `continue` answers an `error`. It never writes the stage.
#### Housekeeping
```http
POST /api/lanes/gc
```
Same-origin, admin only (`403 EADMIN` for a developer or analyst — it is machine-wide). Reaps orphaned Playwright MCP processes under `LANES_ROOT` and caps oversized logs under `LANES_ROOT/.state/`. `{ dryRun?: true }` reports without acting. Answers `{ reaped, capped }`.
#### Tail a workspace's log
```http
GET /api/lanes/:id/logs/:svc?tail=65536
```
```json
{ "available": true, "svc": "api", "size": 20481, "truncated": false, "text": "…" }
```
`tail` is the number of trailing bytes (default 64 KiB, capped at 1 MiB). The
resolved path is confined to the 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.
```http
GET /api/lanes/:id/features
```
Every feature this workspace has ever activated, archived or live, most recently
touched first:
```json
{
"features": [
{
"id": 5, "lane_id": 12, "slug": "auth-redesign", "title": "auth-redesign",
"branch": "feat/auth", "pipeline": "default", "stage": "implement",
"stage_since": "2026-08-04T09:00:00.000Z", "status": "running",
"gate_decision": null, "ci_status": null, "qc_dev": null,
"stages": {}, "links": {}, "notes": null, "archived_at": null,
"created_at": "2026-08-04T09:00:00.000Z", "updated_at": "2026-08-04T09:00:00.000Z",
"pipeline_name": "Default", "pipeline_nodes": [ /* same shape as a live lane's */ ], "progress": 40
}
]
}
```
Each row is the hydrated `lane_features` row (`stages`/`links` parsed from
JSON) plus `pipeline_name`/`pipeline_nodes`/`progress`, computed the exact
same way a live 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).
```http
GET /api/lanes/:id/features/:slug
```
One feature by its canonicalized slug, same shape as an entry above, wrapped
as `{ "feature": {...} }`. `404 ENOFEATURE` if that slug was never activated
on this workspace; `404 ENOLANE` if the workspace itself doesn't exist.
```http
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": , "feature": }`.
- **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" } }`.
#### Workspace proof gallery
Proof gallery (QC screenshots) captured at `/.playwright-mcp/proof///` 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.
```http
GET /api/lanes/:id/proof
```
List every feature with proof (or an activated-but-not-yet-shot feature from `lane_features`), most recently touched first:
```json
{
"features": [
{
"slug": "auth-redesign",
"groups": {
"desktop": ["01-login.png", "02-signup.png"],
"mobile": ["01-login.png"]
},
"ticket_report": "auth-redesign/ticket/REPORT.html",
"mtime": 1722755400
}
]
}
```
Each feature has:
- **`slug`** (string) — canonicalized feature slug
- **`groups`** (object) — `{ "phase": ["image1.png", "image2.png"] }` — phase-groups with their images
- **`ticket_report`** (string) — relative path to ticket report HTML, or empty if none exists
- **`mtime`** (number) — Unix seconds of the most recent file touched in this feature's proof tree (0 if the feature has no proof yet)
**Status codes:**
- **200** — success
- **404** `{ "error": { "code": "ENOLANE", "message": "workspace not found" } }` — workspace doesn't exist
```http
GET /api/lanes/:id/proof/:slug/:group/:file
```
Fetch a single proof file (PNG, JPG, or HTML). The file is resolved with path containment checks before serving:
**Status codes:**
- **200** — file content, `Content-Type` set to `image/png`, `image/jpeg`, or `text/html`
- **404** — workspace, slug, group, or file not found, or path escapes containment
```http
DELETE /api/lanes/:id/proof/:slug
```
Delete proof at one of three granularities: request body `{ "group": "phase-name", "images": ["file1.png"] }` deletes specific images from a phase, `{ "group": "phase-name" }` deletes an entire phase, or empty `{}` deletes all phases **except** `ticket/`.
```json
{ "deleted": 3 }
```
**Status codes:**
- **200** — success, returns count of deleted files
- **400** `{ "error": { "code": "EBADPATH", "message": "…" } }` — invalid slug, group, or image name; or attempt to delete `ticket/`
- **404** `{ "error": { "code": "ENOLANE", "message": "workspace not found" } }` — workspace doesn't exist
- **404** `{ "error": { "code": "ENOFEATURE", "message": "…" } }` — slug doesn't exist
```http
POST /api/lanes/:id/proof-link
```
Converge the clone-root `proof/` directory (if it exists at `/proof`) onto the canonical `.playwright-mcp/proof`. This is an explicit operation, never automatic — exposed only as `agenthub workspace proof-link`. Idempotent.
**Response:**
```json
{ "linked": true }
```
- **`linked`** (boolean) — `true` if the link was created or fixed, `false` if the symlink already pointed to the right place or a collision was avoided
**Status codes:**
- **200** — success
- **404** `{ "error": { "code": "ENOLANE", "message": "workspace not found" } }` — workspace doesn't exist
#### Pipeline pass statistics
```http
GET /api/lanes/pipeline-stats
```
How often each pipeline node was actually passed, aggregated over every recorded
run and grouped by pipeline template, busiest first.
Read from `lane_stage_events`, the append-only log every stage declaration
writes — **not** from `lanes.stages`. That blob describes the pass in flight: it
is overwritten when a node is re-entered and truncated when the 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.
```json
{
"pipelines": [
{
"id": "default",
"name": "Default feature pipeline",
"runs": 4,
"nodes": [
{ "id": "plan", "label": "plan", "icon": "🧭", "gate": false,
"entered": 4, "passed": 4, "withEvidence": 4, "failed": 0, "detected": 0 }
]
}
],
"lanes": [{ "id": 1, "title": "api lane", "cwd": "/w/api" }]
}
```
- **`detected`** — runs that reached the node by inference from the hook stream
with no declaration. Counted apart from `entered`: it says the work happened,
not that the gate was met. A node the hooks saw is reported as such rather
than as "never reached", which is what a session that never runs the pipeline
commands used to look like.
- **`lanes`** — every 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
```http
GET /api/workflows?status=active|completed§ions=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
```http
GET /api/workflows/skill-usage?status=active|completed
```
Skill invocations counted from `Skill` tool events — `PreToolUse` rows only,
since every call also emits a `PostToolUse` and counting both would double
every number. `status` filters by session lifecycle, same convention as
`GET /api/workflows`; omit it (or pass `all`) for no filter.
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`.
```json
{
"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____*`),
so a plugin used only through MCP does not report zero.
**Status codes:** **200** always; **500** with `{ "error": { "message" } }` on a
query failure.
#### Workflow-tool runs
```http
GET /api/workflows/runs?status=&session_id=&limit=&offset=
GET /api/workflows/runs/:runId
GET /api/workflows/session/:id
```
`runs` lists the fleets the Claude Code **Workflow** tool launched, ingested from their on-disk journals (`server/lib/workflow-ingest.js`): `{ runs, total, counts, limit, offset }`, newest first, `phases`/`progress` parsed into arrays, `counts` per status; `status` (omit or `all` for every one) or `session_id` filters, `limit` 1–1000 (default 50). A developer sees only runs of their own sessions. `runs/:runId` → `{ workflow, agents, events }` — the run, its inner agents and their events (oldest first, at most 5000); `404 WORKFLOW_NOT_FOUND`. `session/:id` is the session drill-in of the Workflow analytics page: `{ session, tree, toolTimeline, swimLanes, events }` (the agent tree, every tool call in order, one swim lane per agent, the first 500 events); `404` for an unknown session.
### 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
```http
GET /api/locks
```
Every currently-held lock:
```json
{
"locks": [{ "name": "build", "holder": "lane3", "since": 1722702012, "ageSec": 41 }]
}
```
`since` is a Unix **seconds** timestamp; `ageSec` is derived at read time. An
empty array means nothing is held.
#### Acquire a lock
```http
POST /api/locks/:name/acquire
{ "holder": "lane3" }
```
`holder` is **required** — this route does not default it; `agenthub lock
acquire` resolves `lane` 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
```http
POST /api/locks/:name/release
{ "holder": "lane3" }
```
- **200** `{ "ok": true }` — released.
- **409** `{ "error": { "code": "ENOTHOLDER", "message": "...", "currentHolder": "lane1" } }` — `holder` doesn't match the current owner; a release never trusts its caller, same rule every other destructive path in this project follows.
- **404** `{ "error": { "code": "ENOLOCK", "message": "no such lock: build" } }` — nothing by that name is held.
#### Workspace context: instructions, folders, context files, memory
```http
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/clone {"url": "https://host/o/r.git", "name"?: "r", "credentials"?: {username, password, remember?}, "background"?: true}
POST /api/lanes/:id/sources/file raw body, Content-Type: application/octet-stream, X-File-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:
```jsonc
{
"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": "/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.
- **Clone** — clones a related repo BESIDE the workspace folder (into its parent; no path is taken from the request) and adds it as a Folder. The clone is `POST /api/lanes/clone`'s own: the same URL allowlist (`400 EBADURL`), one plain `name` (default: the repo's), the same `partial` and `progressToken`, `409 EEXISTS` for a taken name, run as the folder's account with that account's git identity, `409 ECREDENTIALS` (with `auth: {protocol, host}`) / `ESSHAUTH` / `ENOREPO`; a tool that takes no folders is `400 EUNSUPPORTED`. Answers the view plus `path`. Removing the folder later never deletes the clone. The workspace home's **Add folder** form takes several URLs, one per line, and clones them in turn. Before cloning it checks each URL against `GET /api/me/git`: an SSH link (`ssh://…`, `user@host:path`) while the account has no SSH key is not sent at all and points to Settings → Git (make a key, add its public key to the host), and an HTTPS host with no saved login is flagged with an optional login row — a public repo needs none. The server's own `ECREDENTIALS`/`ESSHAUTH` answers still decide.
- **File** — up to 10 MB (`413 ETOOBIG`), not empty (`400 EEMPTY`). AgentHub stores it under `/workspace-context//` 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 `/projects//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 []` first, then that workspace's instructions folder `/workspace-prompts//` (a member's: `prompt-/` in their handoff dir) as one more `--add-dir`, with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` in the pane's env so Claude reads the `CLAUDE.md` there as memory — never `--append-system-prompt-file`: Claude cannot carry a custom system prompt over a restart and refused `/tui` in such a pane. That `CLAUDE.md` is rewritten at every spawn and holds 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 ` per folder (and the context dir) and `-c developer_instructions=` (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)
```http
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 `/.claude/worktrees/`, `"agenthub"` under `LANES_ROOT`, else absent.
```jsonc
{
"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
```http
GET /api/sessions
```
Returns all sessions, ordered by most recent activity.
**Query Parameters:**
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `limit` | integer | 50 | Maximum sessions to return (1-1000) |
| `offset` | integer | 0 | Pagination offset |
| `status` | string | - | Filter by persisted status: `active`, `completed`, `error`, `abandoned`. The UI **Waiting** state is derived from the `awaiting_input_since` column and is not a `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](#remote-data-sources) |
**Example Request:**
```bash
curl http://localhost:4820/api/sessions?limit=10&status=active
```
**Example Response:**
```json
{
"sessions": [
{
"id": 1,
"session_id": "sess_abc123",
"model": "claude-sonnet-4",
"status": "active",
"total_cost": 1.23,
"agent_count": 3,
"tool_count": 12,
"created_at": "2024-03-18T12:00:00Z",
"updated_at": "2024-03-18T14:30:00Z"
}
],
"total": 42,
"limit": 10,
"offset": 0
}
```
**Response Schema:**
```mermaid
classDiagram
class SessionListResponse {
+Session[] sessions
+number total
+number limit
+number offset
}
class Session {
+string id
+string name
+string status "active|completed|error|abandoned"
+string cwd
+string model
+string started_at
+string ended_at
+string updated_at
+string awaiting_input_since "null unless Waiting"
+string awaiting_reason "notification|stop|session_start|interrupted; null unless Waiting"
+number lane_id "owning workspace; null if none"
+string archived_at "null unless archived"
+string pinned_at "null unless pinned"
+number cost
+number agent_count
+number owner_id "server mode: workspace owner; null if none"
+string owner_name "server mode: owner's username"
+string owner_display_name "server mode"
+string owner_avatar_at "server mode"
}
SessionListResponse --> Session
```
---
#### Create Session
```http
POST /api/sessions
```
Body `{ id, name?, cwd?, model?, metadata? }` (`id` required, else `400 INVALID_INPUT`). An existing id answers `200 { session, created: false }` unchanged; a new one is inserted `active` and broadcast as `session_created` → `201 { session, created: true }`. Sessions normally come from hooks; in server mode a developer or analyst is refused (`403 EADMIN`).
---
#### Update Session
```http
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
```http
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 ` still takes them all).
**Path Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | Session ID (e.g., `sess_abc123`) |
**Example Request:**
```bash
curl http://localhost:4820/api/sessions/sess_abc123
```
**Example Response:**
```json
{
"session": {
"id": 1,
"session_id": "sess_abc123",
"model": "claude-sonnet-4",
"status": "active",
"total_cost": 1.23,
"created_at": "2024-03-18T12:00:00Z",
"updated_at": "2024-03-18T14:30:00Z"
}
}
```
**Error Responses:**
| Code | Description |
|------|-------------|
| 404 | Session not found |
| 500 | Internal server error |
---
#### Get Session Outline
```http
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 `/drops/` dir — `agenthub-drops-` 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____`, a `plugin_` prefix dropped). A file made by a Bash command is not an output here. 404 for an unknown session.
```bash
curl http://localhost:4820/api/sessions/sess_abc123/outline
```
```json
{
"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
```http
GET /api/sessions/:id/image?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 — or one the session NAMED (see `/file` below: a chart its script drew). 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:`), 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.
```bash
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
```http
GET /api/sessions/:id/file?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`). A file the session only NAMED is served too (issue #76: a report its own script generated): `path` appears as a whole path — not as the prefix of a longer one — in one of its `PostToolUse` `Bash` events' `tool_input.command` or `tool_response.stdout`, or in a `Stop`/`SubagentStop` event's `last_assistant_message`, and the session's account could read it itself (`access(2)`, which ignores the S3 unit's `CAP_DAC_READ_SEARCH`; for a member's session the home/owner rule below decides). 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.
```bash
curl "http://localhost:4820/api/sessions/sess_abc123/file?path=/home/me/Documents/REPORT.md"
```
---
#### Get Session Stats
```http
GET /api/sessions/:id/stats
```
Returns aggregated counts powering the Session Detail overview panel. All aggregation runs in SQL — the response is cheap to compute even for sessions with tens of thousands of events.
**Path Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | Session ID |
**Example Request:**
```bash
curl http://localhost:4820/api/sessions/sess_abc123/stats
```
**Example Response:**
```json
{
"session_id": "sess_abc123",
"total_events": 14082,
"events_by_type": [
{ "event_type": "PreToolUse", "count": 5210 },
{ "event_type": "PostToolUse", "count": 5208 }
],
"tools_used": [
{ "tool_name": "Bash", "count": 1842 },
{ "tool_name": "Read", "count": 1340 }
],
"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 |
---
#### Session transcripts
```http
GET /api/sessions/:id/transcripts
GET /api/sessions/:id/transcript?agent_id=&run_id=&limit=&after=&before=&offset=
```
`transcripts` lists the session's conversation files — the main one first, then each subagent's and compaction's — as `{ transcripts: [{ id, name, type: "main"|"subagent"|"compaction", subagent_type?, db_agent_id, … }] }`; `db_agent_id` links an entry to its `agents` row (`null` when none matches). A Codex, Antigravity, Cursor, omp or OpenCode session lists its own tool's journal instead.
`transcript` reads one of them as structured messages: no `agent_id` = the main conversation, `agent_id` (+ `run_id` for a Workflow-tool agent) = a subagent's — each one plain name, never a path. `limit` defaults to 50 (max 200); `after=` returns only messages past that JSONL line (the chat's tail), `before=` the page before it (history), `offset` is the legacy pager. Answers `{ messages, total, has_more, last_line, first_line }` (empty when the file is gone). A Claude Agent/Task `tool_result` whose subagent (or one it started, up to 3 levels) wrote or edited files carries them as `edits: [{ path, added, removed, counted }]`, read from the subagent's own transcript (`server/lib/agent-edits.js`, cached by size + mtime) — the chat's "Edited N files" card counts them with the turn's own edits. A Claude page that starts in the middle of a turn also carries `turn_edits: [{ path, added, removed, counted }]` — the files that turn changed before the page's first message, gathered from the messages the page dropped — so the card stays whole when the turn is longer than `limit`. The live file under the Claude projects folder is preferred, else the snapshot taken at import. `404 NOT_FOUND` for an unknown session.
---
#### Get Session Agents
There is no `/api/sessions/:id/agents` route: a session's agents come with `GET /api/sessions/:id` (its `agents` array) or from `GET /api/agents?session_id=` (see [Agents](#agents)).
> **Note on `cost`** — `/api/agents` and `GET /api/sessions/:id` (its `agents`) attach a `cost` (USD) to each agent: the agent's **own** cost, computed server-side from the per-agent token buckets stored in `agents.metadata.tokens` and priced at the current pricing rules (at the agent's start date, so promo/standard cutovers apply — see [Pricing](#pricing)). It is `0` for main agents (whose cost is the session total, reported by `/api/pricing/cost/:sessionId`), for compaction pseudo-agents, and for any subagent whose transcript is unavailable. This lets a subagent card show only what that subagent spent instead of the whole session's total.
> **Note on `status` vs Waiting** — agents are persisted with one of `idle | connected | working | completed | error`. The yellow **Waiting** badge surfaced in the dashboard is a UI overlay derived from `awaiting_input_since` being non-null on a non-terminal agent (typically `idle` after a `Stop`, or `connected` right after `SessionStart`). Filter `?status=idle` on `/api/agents` and inspect `awaiting_input_since` to enumerate currently-waiting main agents.
---
### Agents
#### List Agents
```http
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`.
```bash
curl "http://localhost:4820/api/agents?session_ids=sess_abc123,sess_def456"
```
#### Get Agent
```http
GET /api/agents/:id
```
Returns single agent details.
**Path Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `id` | string | Agent ID (e.g., `agent_xyz789`) |
**Example Request:**
```bash
curl http://localhost:4820/api/agents/agent_xyz789
```
**Example Response:**
```json
{
"agent": {
"id": 1,
"agent_id": "agent_xyz789",
"session_id": "sess_abc123",
"agent_type": "explore",
"status": "completed",
"current_tool": null,
"input_tokens": 1500,
"output_tokens": 800,
"cost": 0.45,
"created_at": "2024-03-18T12:00:00Z",
"updated_at": "2024-03-18T12:05:00Z"
}
}
```
---
#### Create or update an agent
```http
POST /api/agents
PATCH /api/agents/:id
```
`POST` body `{ id, session_id, name, type?, subagent_type?, status?, task?, parent_agent_id?, metadata? }` (`id`, `session_id`, `name` required, else `400 INVALID_INPUT`): an existing id answers `200 { agent, created: false }` (`404 ENOTFOUND` when it is another user's in server mode); a new one is inserted (`type` default `main`, `status` default `waiting`), broadcast as `agent_created` → `201 { agent, created: true }`. `PATCH` takes any of `name`, `status`, `task`, `current_tool`, `ended_at`, `metadata` and answers `{ agent }`; `404 NOT_FOUND` for an unknown id. Agents normally come from hooks.
#### Tool Execution Flow
Tool calls are recorded as `PreToolUse`/`PostToolUse` events, not as a separate table — read them with `GET /api/events?agent_id=` or `?tool_name=`.
```mermaid
sequenceDiagram
participant Agent
participant PreHook as PreToolUse Hook
participant Tool as Tool Execution
participant PostHook as PostToolUse Hook
participant DB as Database
Agent->>PreHook: Tool about to execute
PreHook->>DB: Store PreToolUse event, set current_tool
Agent->>Tool: Execute (bash, view, etc.)
Tool-->>Agent: Result
Agent->>PostHook: Tool completed
PostHook->>DB: Store PostToolUse event
PostHook->>DB: Clear current_tool
PostHook->>DB: Update token counts + cost
```
---
### Events
#### List Events
```http
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`).
---
#### Event filter values
```http
GET /api/events/facets
```
`{ event_types, tool_names }` — the distinct values in the events table, sorted, for the Events page's filters (a developer's: only their own sessions' events).
---
### Data cleanup
#### Clean up old data
```http
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
```http
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
```http
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 `/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
```http
POST /api/settings/quit
```
Settings → System's **Quit AgentHub** and `agenthub stop` (issue #65; same-origin only, admin-only in server mode). Writes `/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
```http
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](CLI.md)), the database included. `log` is `$TMPDIR/agenthub-uninstall-.log`, the only record left afterwards.
#### Clear, re-import, export and restore data
```http
POST /api/settings/clear-data
POST /api/settings/reimport
GET /api/settings/export
POST /api/settings/import
POST /api/settings/reset-pricing
```
`clear-data` deletes every session, agent, event, token row, workflow run, fired alert and webhook delivery in one transaction (alert rules, webhook targets, pricing, snapshots and reports survive) → `{ ok: true, cleared: ] }`. `reimport` re-reads the Claude projects folder (`importAllSessions`) → `{ ok: true, imported, skipped, errors, … }`; `500 IMPORT_FAILED`. `export` streams one JSON attachment (`agent-monitor-export-.json`: `format`, `version`, `exported_at`, `sessions`, `agents`, `events`, `token_usage`, `workflows`, `dashboard_runs`, `alert_rules`, `model_pricing`), events written in pages so a large database never sits in memory. `import` (same-origin) restores such a file sent as multipart `file` (≤ 512 MB, else `413 FILE_TOO_LARGE`): non-destructive — a session already here is skipped whole — → `{ ok: true, source, format, …counts }`; `400 NO_FILE` / `BAD_UPLOAD` / `INVALID_FORMAT`. `reset-pricing` reseeds the default model prices (and intro rates), then waits up to 3 s for the automatic price sync → `{ ok: true, pricing }`; `{ scope: "claude" }` resets only the `claude-…` rows. All five are admin-only in server mode, and `export` is staff-only.
#### Hooks for Claude Code, Codex and Antigravity
```http
POST /api/settings/reinstall-hooks
GET /api/settings/codex-hooks
POST /api/settings/codex-hooks
DELETE /api/settings/codex-hooks
GET /api/settings/antigravity-hooks
POST /api/settings/antigravity-hooks
DELETE /api/settings/antigravity-hooks
```
`reinstall-hooks` rewrites AgentHub's hooks in Claude Code's `settings.json` → `{ ok, hooks }` (`500 HOOK_INSTALL_FAILED`). `codex-hooks` reads, installs (or reinstalls) and removes AgentHub's entries in `$CODEX_HOME/hooks.json` (`server/lib/codex-hooks.js`); every answer is the status `{ supported, path, script, readable, installed, trusted, events: { : { installed, trusted } }, error? }` — `trusted` is read from Codex's own `config.toml`, which AgentHub never writes. `antigravity-hooks` does the same for the named hook `agenthub` in `~/.gemini/config/hooks.json` (status `{ supported, path, script, readable, installed, enabled, events }`). Only on a click; the writes are same-origin; `400 EBADJSON` / `EUNSUPPORTED` / `EFOREIGN` (a forwarder script that is not AgentHub's), else `500 ESAVE`.
#### Privacy
```http
GET /api/settings/privacy
PUT /api/settings/privacy
```
`{ redact_secrets, ignore_cwd, env_owned: { redact_secrets, ignore_cwd } }` — opt-in redaction of secrets in stored payloads, and the folders whose hooks are dropped (`server/lib/privacy.js`). `PUT` (same-origin) takes either field (`redact_secrets` boolean, `ignore_cwd` a list of absolute folders) and answers `{ ok: true, …state }`; `400 INVALID_INPUT` / `EBADPATH` (not absolute, a filesystem root, over 100 entries), `409 EENVOWNED` when the environment sets it, `500 ESAVE`.
#### Push ingest token
```http
GET /api/settings/push-token
POST /api/settings/push-token
DELETE /api/settings/push-token
```
The token for [`POST /api/hooks/ingest-batch`](#push-ingest-post-apihooksingest-batch). `GET` → `{ enabled, env_owned }` — never the token. `POST` (same-origin) makes a new one (the old one stops working) and returns it THIS ONCE: `{ ok: true, token, enabled, env_owned }`; `DELETE` turns push ingest off → `{ ok: true, enabled: false, env_owned }`. `409 EENVOWNED` when `AGENTHUB_PUSH_TOKEN_SHA256` comes from the environment.
---
### Tools
There is no `/api/tools` route. Per-tool counts come from `GET /api/sessions/:id/stats` (`tools_used`, `tool_call_count`) and `GET /api/events/facets`; individual calls from `GET /api/events?tool_name=`.
---
### Metrics
#### Prometheus exposition
```
GET /api/metrics
```
Exposes the dashboard's live counters in the [Prometheus text-exposition format](https://prometheus.io/docs/instrumenting/exposition_formats/) (v0.0.4) so this monitoring dashboard can itself be scraped into Prometheus / Grafana. Read-only. Values are read from the same prepared statements the REST API uses, so they match the UI.
Response `Content-Type: text/plain; version=0.0.4; charset=utf-8`.
| Metric | Type | Labels | Meaning |
| --- | --- | --- | --- |
| `agenthub_up` | gauge | — | `1` when the API served the scrape |
| `agenthub_build_info` | gauge | `version` | Always `1`; dashboard version rides on the label |
| `agenthub_process_uptime_seconds` | gauge | — | Server process uptime |
| `agenthub_process_resident_memory_bytes` | gauge | — | Server process RSS |
| `agenthub_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`):
```yaml
scrape_configs:
- job_name: agenthub
metrics_path: /api/metrics
static_configs:
- targets: ["host.docker.internal:4820"]
# authorization: # only if DASHBOARD_TOKEN is set
# credentials: "