# 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 — admin or analyst, `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 } 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. #### 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= ``` 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 a `progressToken` and shows the queue place / git progress while the clone runs; closing the dialog then only hides it behind a progress chip (click to reopen) — the request keeps going and its outcome arrives as a toast. #### 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 form polls it every second (`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. #### 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: