Files
AgentHub/server/openapi-extra/ops-more.js
T
nntrivi2001 57f02f0d89 docs: OpenAPI and API.md cover every route; drop removed endpoints; guard config writes
- openapi-extra/run.js describes tmux pane runs only: the removed
  POST /api/run/{id}/message and stream-json run_stream/run_input_ack are gone.
- 108 operations added to OpenAPI (lanes, run, settings, reports, cc-config,
  pets, ai-proxies, tool-profiles, ai-tools, snapshots, locks, ...);
  openapi.yaml regenerated.
- docs/API.md: 74 routes added, 4 endpoints that no longer exist removed.
- Role is `developer`, not `member` (CLI.md, DATABASE.md, OVERVIEW.md);
  `agenthub update` -> `update-check`; help lists `server install-system|tools`
  and `users provision`; ARCHITECTURE.md's stale paths and names fixed.
- cc-config/codex-config writes carry sameOriginGuard (an MCP server or plugin
  is code Claude runs later), with a test; `command -v` takes its name as $1.
2026-10-22 12:17:18 +07:00

1054 lines
41 KiB
JavaScript

/**
* @file Supplementary OpenAPI 3.0 fragment for routes that had no operation in
* the spec: the run composer's drops and pane helpers (`/api/run/*`), the
* privacy / push-token / quit / uninstall settings, scheduled reports, session
* snapshots, the mascot's pets, budgets status, the Query Explorer, plan quota,
* users' storage, every home's trash, a session's file/image reads, skill
* usage, alert-rule preview, push ingest and an artifact's installs/uninstall.
* Exports `{ tags, schemas, paths }` for `server/openapi-extra.js`; every key
* here is a path no other fragment defines (a path key REPLACES a whole entry).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const tags = [
{ name: "Reports", description: "Scheduled usage reports and their run files" },
{ name: "Snapshots", description: "Immutable, token-addressed session snapshots" },
{ name: "Pets", description: "The mascot's custom pets (uploads and fixed featured ids)" },
{ name: "Query", description: "Query Explorer: filtered, sorted pages and exports" },
];
const schemas = {};
const err = { $ref: "#/components/schemas/ErrorResponse" };
const errs = (description) => ({
description,
content: { "application/json": { schema: err } },
});
const ok = (description, properties) => ({
description,
content: {
"application/json": {
schema: properties ? { type: "object", properties } : { type: "object" },
},
},
});
const json = (properties, required = []) => ({
required: true,
content: { "application/json": { schema: { type: "object", required, properties } } },
});
const pathParam = (name, description, schema = { type: "string" }) => ({
name,
in: "path",
required: true,
description,
schema,
});
const queryParam = (name, description, schema = { type: "string" }) => ({
name,
in: "query",
required: false,
description,
schema,
});
const okTrue = { ok: { type: "boolean", enum: [true] } };
const binary = (type) => ({ [type]: { schema: { type: "string", format: "binary" } } });
const laneIdQuery = queryParam(
"laneId",
"The workspace whose pane will read the file: the drop is written to the handoff dir of the account that workspace runs as (S3), else the requester's own.",
{ type: "integer" }
);
const rangeParams = [
queryParam("from", "Local start day, `YYYY-MM-DD`.", { type: "string", format: "date" }),
queryParam("to", "Local end day (inclusive), `YYYY-MM-DD`.", { type: "string", format: "date" }),
queryParam("tz_offset", "Browser `getTimezoneOffset()` in minutes.", { type: "integer" }),
queryParam("tool", "Only sessions of this AI Tools id (400 INVALID_INPUT if malformed)."),
];
const reportBody = {
name: { type: "string", maxLength: 200 },
window_days: { type: "integer", minimum: 1, maximum: 366 },
frequency: { type: "string", enum: ["daily", "weekly"] },
hour: { type: "integer", minimum: 0, maximum: 23 },
weekday: { type: "integer", minimum: 0, maximum: 6, description: "0 = Sunday; default 1." },
tz_offset: { type: "integer", minimum: -840, maximum: 840, description: "Minutes; default 0." },
formats: { type: "array", items: { type: "string", enum: ["html", "json"] }, minItems: 1 },
enabled: { type: "boolean", description: "Default true." },
};
const reportIdParam = pathParam("id", "Report definition id.", { type: "integer" });
const reportNotFound = errs("NOT_FOUND — no such report.");
const paths = {
// ── Run composer helpers ──────────────────────────────────────────────────
"/api/run/drop-image": {
post: {
tags: ["Run"],
summary: "Save an image dropped onto a terminal",
operationId: "runDropImage",
description:
"The raw body is a PNG, JPEG, GIF or WebP image (`Content-Type` picks the extension; the name is random). It is saved on the machine the pane runs on — the drop dir of the account `?laneId=` runs as (S3: written as that member), else this account's data dir — and the client pastes the returned path into the pane. Files older than a day are pruned on each write. A declared body over the limit (20 MB; 2 GB for a local browser outside server mode) is refused before it is read.",
parameters: [laneIdQuery],
requestBody: {
required: true,
content: {
...binary("image/png"),
...binary("image/jpeg"),
...binary("image/gif"),
...binary("image/webp"),
},
},
responses: {
200: ok("Saved.", { path: { type: "string" } }),
400: errs("Not a png/jpeg/gif/webp body, or empty."),
413: errs("ETOOBIG (`max_bytes` in the error)."),
500: errs("Write failed."),
},
},
},
"/api/run/drop-file": {
post: {
tags: ["Run"],
summary: "Save any other file dropped onto a terminal",
operationId: "runDropFile",
description:
"Like drop-image for any file type. `?name=` is reduced to a basename of `[A-Za-z0-9._-]` behind a random hex prefix. A local browser's drop (loopback, not server mode) is streamed to disk as it arrives (up to 2 GB); otherwise the limit is 20 MB.",
parameters: [
{ ...queryParam("name", "The dropped file's own name (sanitized)."), required: true },
laneIdQuery,
],
requestBody: { required: true, content: binary("application/octet-stream") },
responses: {
200: ok("Saved.", { path: { type: "string" } }),
400: errs("`?name=` or a non-empty body missing."),
413: errs("ETOOBIG."),
500: errs("Write failed."),
},
},
},
"/api/run/drop/{name}": {
get: {
tags: ["Run"],
summary: "Read back a dropped image or text file",
operationId: "runGetDrop",
description:
"Only a name drop-image/drop-file made (16 hex chars, optional `-name`, extension) and only raster images or text types (txt, md, csv, tsv, log, json, xml, yaml…, served as text/plain). Opened with O_NOFOLLOW and checked on the descriptor: a regular file owned by the account whose drop dir it is. Served under a `sandbox` CSP and `nosniff`.",
parameters: [pathParam("name", "The drop's file name."), laneIdQuery],
responses: {
200: {
description: "The file.",
content: { ...binary("image/png"), "text/plain": { schema: { type: "string" } } },
},
404: errs("ENOTFOUND."),
},
},
},
"/api/run/local-paths": {
post: {
tags: ["Run"],
summary: "Check file:// paths a local browser dropped",
operationId: "runCheckLocalPaths",
description:
"For a browser on this machine: answers each path's size when it is a regular file here, so the composer can attach by path instead of uploading. 403 EREMOTE in server mode or for any non-loopback (or proxied) request.",
requestBody: json(
{ paths: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 100 } },
["paths"]
),
responses: {
200: ok("One entry per path, in order.", {
sizes: { type: "array", items: { type: "integer", nullable: true } },
}),
400: errs("EBADFIELD — paths must be 1-100 absolute paths."),
403: errs("EREMOTE."),
},
},
},
"/api/run/interrupted": {
get: {
tags: ["Run"],
summary: "Sessions whose pane died under them",
operationId: "runListInterrupted",
description:
"Runs left `interrupted` (pane gone without the user ending it — reboot, killed tmux, crash) with a session and no newer run of it, newest first, at most 50: the **Resume interrupted sessions?** prompt. Server mode: the caller's own workspaces only. Resumes nothing.",
responses: {
200: ok("Interrupted runs.", {
items: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string" },
session_id: { type: "string" },
lane_id: { type: "integer" },
tool: { type: "string" },
cwd: { type: "string" },
model: { type: "string", nullable: true },
permission_mode: { type: "string", nullable: true },
effort: { type: "string", nullable: true },
prompt_preview: { type: "string", nullable: true },
started_at: { type: "string" },
ended_at: { type: "string", nullable: true },
lane_title: { type: "string", nullable: true },
lane_cwd: { type: "string" },
session_name: { type: "string", nullable: true },
},
},
},
}),
},
},
},
"/api/run/interrupted/dismiss": {
post: {
tags: ["Run"],
summary: "Stop offering interrupted sessions",
operationId: "runDismissInterrupted",
description:
"Marks those sessions' interrupted runs `abandoned` (the caller's own workspaces in server mode). Resumes nothing.",
requestBody: json(
{ sessionIds: { type: "array", items: { type: "string" }, maxItems: 100 } },
["sessionIds"]
),
responses: {
200: ok("Rows changed.", { dismissed: { type: "integer" } }),
400: errs("EBADFIELD."),
},
},
},
"/api/run/tmux": {
get: {
tags: ["Run"],
summary: "Whether tmux is installed, and its version",
operationId: "runTmuxStatus",
description:
"`upgrade` is this platform's advice when the binary is older than AgentHub needs, else null; the Workspace shows it.",
responses: {
200: ok("tmux status.", {
available: { type: "boolean" },
version: { type: "string", nullable: true },
upgrade: { type: "string", nullable: true },
}),
},
},
},
"/api/run/{id}/permission-mode": {
get: {
tags: ["Run"],
summary: "A live claude pane's permission mode",
operationId: "runGetPermissionMode",
description:
"Read off the mode line Claude Code prints under its prompt; `null` while none is shown (a menu or dialog is open).",
parameters: [pathParam("id", "Run id.")],
responses: {
200: ok("Mode.", {
mode: {
type: "string",
nullable: true,
enum: ["default", "acceptEdits", "plan", "auto", "bypassPermissions", "dontAsk", null],
},
}),
400: errs("EUNSUPPORTED — not a claude pane."),
404: errs("ENOTFOUND — no such live run."),
},
},
post: {
tags: ["Run"],
summary: "Change a live claude pane's permission mode",
operationId: "runSetPermissionMode",
description:
"Presses Shift+Tab in the pane, verifying the mode line after every press, until it shows `mode`; presses nothing while the mode line cannot be read. One change at a time per pane. Only this pane's session is affected.",
parameters: [pathParam("id", "Run id.")],
requestBody: json(
{
mode: {
type: "string",
enum: ["default", "acceptEdits", "plan", "auto", "bypassPermissions"],
},
},
["mode"]
),
responses: {
200: ok("The mode now shown.", { mode: { type: "string" } }),
400: errs("EBADMODE, or EUNSUPPORTED (not a claude pane)."),
404: errs("ENOTFOUND."),
409: errs(
"EBUSY (a change is running), EMODEUNKNOWN (no readable mode line) or EMODEUNAVAILABLE (not in this session's cycle)."
),
},
},
},
// ── Settings ──────────────────────────────────────────────────────────────
"/api/settings/privacy": {
get: {
tags: ["Settings"],
summary: "Secret redaction and ignored folders",
operationId: "getPrivacySettings",
responses: {
200: ok("Privacy state.", {
redact_secrets: { type: "boolean" },
ignore_cwd: { type: "array", items: { type: "string" } },
env_owned: {
type: "object",
properties: {
redact_secrets: { type: "boolean" },
ignore_cwd: { type: "boolean" },
},
},
}),
},
},
put: {
tags: ["Settings"],
summary: "Change secret redaction and ignored folders",
operationId: "putPrivacySettings",
description:
"Opt-in redaction of secrets in stored payloads, and folders whose hooks are dropped. Persists to the settings file; a key the environment set is refused. Same-origin only. Each ignored folder must be absolute, not a filesystem root; at most 100.",
requestBody: json({
redact_secrets: { type: "boolean" },
ignore_cwd: { type: "array", items: { type: "string" }, maxItems: 100 },
}),
responses: {
200: ok("`{ok: true}` plus the new privacy state."),
400: errs("INVALID_INPUT or EBADPATH."),
409: errs("EENVOWNED."),
500: errs("ESAVE."),
},
},
},
"/api/settings/push-token": {
get: {
tags: ["Settings"],
summary: "Whether push ingest has a token",
operationId: "getPushTokenStatus",
description: "Never returns the token itself.",
responses: {
200: ok("Status.", { enabled: { type: "boolean" }, env_owned: { type: "boolean" } }),
},
},
post: {
tags: ["Settings"],
summary: "Make a new push-ingest token",
operationId: "rotatePushToken",
description:
"Creates a token for `POST /api/hooks/ingest-batch` (stored as a SHA-256 hash); the previous one stops working. The token is returned ONCE, here. Same-origin only.",
responses: {
200: ok("New token and status.", {
ok: { type: "boolean" },
token: { type: "string" },
enabled: { type: "boolean" },
env_owned: { type: "boolean" },
}),
409: errs("EENVOWNED — the token is set by the environment."),
500: errs("ESAVE."),
},
},
delete: {
tags: ["Settings"],
summary: "Revoke the push-ingest token",
operationId: "revokePushToken",
description: "Push ingest answers 503 again until a new token exists. Same-origin only.",
responses: {
200: ok("Status.", {
ok: { type: "boolean" },
enabled: { type: "boolean" },
env_owned: { type: "boolean" },
}),
409: errs("EENVOWNED."),
500: errs("ESAVE."),
},
},
},
"/api/settings/quit": {
post: {
tags: ["Settings"],
summary: "Quit AgentHub",
operationId: "quitDashboard",
description:
"Writes the `stopped` flag, kills every lane pane, then stops this server once the answer is sent — through its systemd unit / LaunchAgent when one runs it, else by signalling itself. It stays stopped (the bootstrap starts none) until a server starts again (`agenthub start`, or boot/login through an enabled autostart unit). Same-origin only.",
responses: {
200: ok("Quitting.", { ok: { type: "boolean" }, killed: { type: "integer" } }),
},
},
},
"/api/settings/uninstall": {
post: {
tags: ["Settings"],
summary: "Uninstall AgentHub",
operationId: "uninstallDashboard",
description:
"Starts `scripts/uninstall.js --yes` as a detached worker (under systemd in its own `systemd-run --user --scope`), which removes only AgentHub's own panes, unit, servers, config entries, plugin, files and data dir — never the user's work — and stops this server. Refused in server mode. Same-origin only.",
requestBody: json({ confirm: { type: "boolean", enum: [true] } }, ["confirm"]),
responses: {
202: ok("Started.", { ok: { type: "boolean" }, log: { type: "string" } }),
400: errs("ECONFIRM."),
403: errs("ESERVERMODE — run `agenthub uninstall` on the host."),
},
},
},
// ── Reports ───────────────────────────────────────────────────────────────
"/api/reports": {
get: {
tags: ["Reports"],
summary: "List report definitions",
operationId: "listReports",
description: "Every definition with its latest run (`last_run`, or null).",
responses: { 200: ok("Reports.", { reports: { type: "array", items: { type: "object" } } }) },
},
post: {
tags: ["Reports"],
summary: "Create a report definition",
operationId: "createReport",
description:
"The slot due now counts as claimed, so a new report never runs for an hour already past. Same-origin only.",
requestBody: json(reportBody, ["name", "window_days", "frequency", "hour", "formats"]),
responses: {
201: ok("Created.", { report: { type: "object" } }),
400: errs("INVALID_INPUT."),
},
},
},
"/api/reports/{id}": {
put: {
tags: ["Reports"],
summary: "Update a report definition",
operationId: "updateReport",
description: "Omitted fields keep their value. Same-origin only.",
parameters: [reportIdParam],
requestBody: json(reportBody),
responses: {
200: ok("Updated.", { report: { type: "object" } }),
400: errs("INVALID_INPUT."),
404: reportNotFound,
},
},
delete: {
tags: ["Reports"],
summary: "Delete a report definition",
operationId: "deleteReport",
description:
"Deletes the definition, its runs and their files under `<data dir>/reports/<id>/`. Same-origin only.",
parameters: [reportIdParam],
responses: { 200: ok("Deleted.", okTrue), 404: reportNotFound },
},
},
"/api/reports/{id}/run": {
post: {
tags: ["Reports"],
summary: "Run a report now",
operationId: "runReport",
description:
"Also when disabled; recorded with trigger `manual`. A failed build is a run with `status: error`. Same-origin only.",
parameters: [reportIdParam],
responses: {
200: ok("The run.", {
run: {
type: "object",
properties: {
id: { type: "integer" },
report_id: { type: "integer" },
created_at: { type: "string" },
trigger: { type: "string", enum: ["manual", "schedule"] },
status: { type: "string", enum: ["ok", "error"] },
window_from: { type: "string" },
window_to: { type: "string" },
stamp: { type: "string" },
files: { type: "array", items: { type: "string", enum: ["html", "json"] } },
error: { type: "string", nullable: true },
},
},
}),
404: reportNotFound,
},
},
},
"/api/reports/{id}/runs": {
get: {
tags: ["Reports"],
summary: "A report's run log",
operationId: "listReportRuns",
description: "Newest first, the last 100.",
parameters: [reportIdParam],
responses: {
200: ok("Runs.", { runs: { type: "array", items: { type: "object" } } }),
404: reportNotFound,
},
},
},
"/api/reports/{id}/runs/{runId}/{format}": {
get: {
tags: ["Reports"],
summary: "Download one file of a report run",
operationId: "downloadReportRun",
description:
"The file path is built from the report and run ids and the stored stamp only, never from the request. Sent as an attachment `report-<id>-<runId>.<format>`.",
parameters: [
reportIdParam,
pathParam("runId", "Run id.", { type: "integer" }),
pathParam("format", "File format.", { type: "string", enum: ["html", "json"] }),
],
responses: {
200: {
description: "The file.",
content: {
"text/html": { schema: { type: "string" } },
"application/json": { schema: { type: "object" } },
},
},
404: errs("NOT_FOUND."),
},
},
},
// ── Snapshots ─────────────────────────────────────────────────────────────
"/api/snapshots": {
get: {
tags: ["Snapshots"],
summary: "List session snapshots",
operationId: "listSnapshots",
description:
"Newest first, without their payload. Server mode: a developer gets only their own sessions' snapshots.",
responses: {
200: ok("Snapshots.", {
snapshots: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "integer" },
token: { type: "string" },
session_id: { type: "string" },
created_at: { type: "string" },
session_name: { type: "string", nullable: true },
redacted: { type: "boolean" },
event_count: { type: "integer" },
bytes: { type: "integer" },
},
},
},
}),
},
},
post: {
tags: ["Snapshots"],
summary: "Freeze a session into a snapshot",
operationId: "createSnapshot",
description:
"Captures the session row, agents, events (oldest first), its stats and token cost into one immutable row named by 24 random bytes (base64url). With `redact.file_paths`, path-valued keys become `[redacted]` and the cwd/home inside other strings `<cwd>`/`~` AT CAPTURE, so nothing redacted is stored. Same-origin only.",
requestBody: json(
{
session_id: { type: "string" },
redact: { type: "object", properties: { file_paths: { type: "boolean" } } },
},
["session_id"]
),
responses: {
201: ok("Created.", {
snapshot: {
type: "object",
properties: {
id: { type: "integer" },
token: { type: "string" },
session_id: { type: "string" },
created_at: { type: "string" },
},
},
}),
400: errs("INVALID_INPUT."),
404: errs("NOT_FOUND — no such session."),
},
},
},
"/api/snapshots/{ref}": {
get: {
tags: ["Snapshots"],
summary: "Read a snapshot by its token",
operationId: "getSnapshot",
description:
"`ref` is the snapshot's 32-character token (never its id). Answers `{snapshot: {id, token, session_id, created_at}, payload}` with the payload exactly as stored.",
parameters: [pathParam("ref", "GET: the token; DELETE: the numeric id.")],
responses: {
200: ok("Snapshot and payload.", {
snapshot: { type: "object" },
payload: { type: "object" },
}),
404: errs("NOT_FOUND."),
},
},
delete: {
tags: ["Snapshots"],
summary: "Delete a snapshot by its id",
operationId: "deleteSnapshot",
description: "`ref` is the numeric id. Same-origin only.",
parameters: [pathParam("ref", "GET: the token; DELETE: the numeric id.")],
responses: { 200: ok("Deleted.", okTrue), 404: errs("NOT_FOUND.") },
},
},
// ── Pets ──────────────────────────────────────────────────────────────────
"/api/pets": {
get: {
tags: ["Pets"],
summary: "List pets and the featured ones",
operationId: "listPets",
responses: {
200: ok("Pets.", {
pets: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string" },
displayName: { type: "string" },
description: { type: "string" },
kind: { type: "string", enum: ["codex", "image"] },
source: { type: "string" },
featuredId: { type: "string" },
by: { type: "string" },
},
},
},
featured: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string" },
displayName: { type: "string" },
by: { type: "string" },
installed: { type: "boolean" },
page: { type: "string" },
},
},
},
sheet: { type: "object", description: "Sprite sheet geometry." },
}),
},
},
},
"/api/pets/{id}": {
delete: {
tags: ["Pets"],
summary: "Delete a pet",
operationId: "deletePet",
description: "Same-origin only.",
parameters: [pathParam("id", "Pet id (server-made).")],
responses: { 200: ok("Deleted.", okTrue), 404: errs("ENOPET.") },
},
},
"/api/pets/{id}/sprite": {
get: {
tags: ["Pets"],
summary: "A pet's sprite sheet or image",
operationId: "getPetSprite",
description: "Raster only (WebP sheet, or a PNG/JPEG/GIF/WebP upload), sent with `nosniff`.",
parameters: [pathParam("id", "Pet id.")],
responses: {
200: {
description: "The image.",
content: { ...binary("image/webp"), ...binary("image/png") },
},
404: errs("ENOPET."),
},
},
},
"/api/pets/import": {
post: {
tags: ["Pets"],
summary: "Import a .codex-pet.zip",
operationId: "importPet",
description:
"Raw zip body (≤ 8 MB): `pet.json` + `spritesheet.webp`, at the root or in one top-level folder; entry sizes are checked from the zip directory before inflating and nothing else is read. At most 50 pets. Same-origin only.",
requestBody: { required: true, content: binary("application/zip") },
responses: {
201: ok("Imported.", { pet: { type: "object" } }),
400: errs("EBADPET and other validation codes."),
409: errs("ETOOMANY."),
413: errs("ETOOBIG."),
},
},
},
"/api/pets/upload": {
post: {
tags: ["Pets"],
summary: "Upload an image as a pet",
operationId: "uploadPet",
description:
"Raw PNG/JPEG/GIF/WebP body (≤ 4 MB, sniffed — never SVG); `?name=` is the display name. Same-origin only.",
parameters: [queryParam("name", "Display name (extension dropped, ≤ 60 chars).")],
requestBody: { required: true, content: binary("application/octet-stream") },
responses: {
201: ok("Added.", { pet: { type: "object" } }),
400: errs("EBADPET."),
409: errs("ETOOMANY."),
413: errs("ETOOBIG."),
},
},
},
"/api/pets/featured/{id}": {
post: {
tags: ["Pets"],
summary: "Install a featured pet",
operationId: "installFeaturedPet",
description:
"Downloads one of the fixed featured pets from codex-pets.net into the data dir. Only a fixed featured id is accepted — no URL or path. Same-origin only.",
parameters: [pathParam("id", "Featured pet id.")],
responses: {
201: ok("Installed.", { pet: { type: "object" } }),
403: errs("EBADORIGIN."),
404: errs("ENOPET — not a featured pet."),
409: errs("ETOOMANY."),
413: errs("ETOOBIG."),
502: errs("EFETCH."),
},
},
},
// ── Misc reads and actions ────────────────────────────────────────────────
"/api/budgets/status": {
get: {
tags: ["Alerts"],
summary: "Every enabled budget's current-period spend",
operationId: "getBudgetStatus",
description:
"A budget is an alert rule of type `budget` (edited through /api/alerts/rules); this only reads. Server mode: a developer's spend over their own sessions only.",
responses: {
200: ok("Budgets.", {
budgets: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "integer" },
name: { type: "string" },
config: { type: "object" },
period_start: { type: "string" },
period_end: { type: "string" },
spend: { type: "number" },
projected: { type: "number" },
pct: { type: "number" },
crossed: { type: "array", items: { type: "number" } },
},
},
},
}),
},
},
},
"/api/query": {
get: {
tags: ["Query"],
summary: "Query Explorer: filter, sort, page or export one table",
operationId: "runQuery",
description:
"Filters sessions, events or agents (free text, tool, status, local date range, cwd, data scope) and sorts by a per-entity allowlisted column; every value is a bound parameter. With `format=csv|json` the whole filtered set (capped at 50,000 rows) is sent as a download, with `X-Total-Count` and `X-Truncated` headers.",
parameters: [
queryParam("entity", "Default `sessions`.", {
type: "string",
enum: ["sessions", "events", "agents"],
}),
queryParam("q", "Free text (LIKE over the entity's text columns)."),
queryParam("status", "One of the entity's statuses."),
queryParam("cwd", "The folder or anything under it."),
queryParam("sort", "An allowlisted column of the entity."),
queryParam("dir", "Default `desc`.", { type: "string", enum: ["asc", "desc"] }),
queryParam("limit", "Page size, 1-500 (default 50).", { type: "integer" }),
queryParam("offset", "Default 0.", { type: "integer" }),
queryParam("format", "Export instead of a page.", {
type: "string",
enum: ["csv", "json"],
}),
...rangeParams,
],
responses: {
200: {
description:
"A page `{entity, columns, sortable, statuses, rows, total, limit, offset, export_cap}`, or the export (`{entity, total, truncated, cap, rows}` / CSV).",
content: {
"application/json": { schema: { type: "object" } },
"text/csv": { schema: { type: "string" } },
},
},
400: errs("INVALID_INPUT."),
},
},
},
"/api/pricing/quota": {
get: {
tags: ["Pricing"],
summary: "Plan quota spent per AI tool",
operationId: "getPlanQuota",
description:
"Percentages, window lengths, reset times, plan name and account email per tool (Claude live + saved profiles, Codex, Antigravity, Cursor, …) — never a token. Cached; `?force=1` (a Refresh click) refetches, still rate-limited. Server mode with S3: a non-admin gets only their own Linux account's quota; a developer sees no account names.",
parameters: [queryParam("force", "`1` to refetch.", { type: "string", enum: ["1"] })],
responses: {
200: ok("Quota.", {
tools: {
type: "array",
items: {
type: "object",
description:
"`{id, label, account, windows: [{label, usedPercent, resetsAt}], error?}`.",
},
},
checkedAt: { type: "string" },
}),
},
},
},
"/api/users/storage": {
get: {
tags: ["Auth"],
summary: "Disk used by each user's home",
operationId: "getUsersStorage",
description:
"Server mode, staff only. Each home is walked in a worker (lstat only, one file system, allocated blocks), cached 15 minutes; a read returns the cache at once and measures stale homes in the background. A user with no home of their own is `null`.",
parameters: [
queryParam("refresh", "`1` re-measures every home.", { type: "string", enum: ["1"] }),
],
responses: {
200: ok("Storage by user id.", {
storage: {
type: "object",
additionalProperties: {
type: "object",
nullable: true,
properties: {
bytes: { type: "integer" },
files: { type: "integer" },
partial: { type: "boolean" },
measuredAt: { type: "string" },
},
},
},
measuring: { type: "boolean" },
}),
},
},
},
"/api/homes/trash": {
get: {
tags: ["Files"],
summary: "Every recycle bin the caller may open",
operationId: "listAllHomeTrash",
description:
"One list over every home the caller sees (an admin: every user's), each item tagged with its `home` (the `:who` a restore or purge goes to) and `owner`. A home whose trash cannot be read is in `errors`. Reading another user's trash is audited.",
responses: {
200: ok("Trash items, newest first.", {
items: { type: "array", items: { type: "object" } },
errors: {
type: "array",
items: {
type: "object",
properties: {
home: { type: "string" },
owner: { type: "string" },
message: { type: "string" },
},
},
},
keepDays: { type: "integer" },
}),
},
},
},
"/api/sessions/{id}/image": {
get: {
tags: ["Sessions"],
summary: "An image the session read",
operationId: "getSessionImage",
description:
"Serves `?path=` (absolute, png/jpeg/gif/webp, ≤ 20 MB) only when the session's own Read touched it, or its Bash/replies named it as a whole path and the session's account could read it itself. Never for a pushed session, nor in server mode without S3; under S3 a member's session only inside their home on a file they own. Sandbox CSP + `nosniff`.",
parameters: [
pathParam("id", "Session id."),
{ ...queryParam("path", "Absolute image path."), required: true },
],
responses: {
200: { description: "The image.", content: binary("image/png") },
400: errs("ENOTIMAGE or ETOOBIG."),
403: errs("EUNSUPPORTED."),
404: errs("NOT_FOUND / ENOTFOUND."),
},
},
},
"/api/sessions/{id}/file": {
get: {
tags: ["Sessions"],
summary: "A text file the session touched",
operationId: "getSessionFile",
description:
"For the Chat pane's Outputs/Files preview: `?path=` only when the session's own Read/Write/Edit/MultiEdit/NotebookEdit named it, or its Bash/replies named it and its account could read it. Same authorization as `/image`, plus never a credential by name or link target (ESECRET), never binary or non-regular; at most 512 KB (`truncated`).",
parameters: [
pathParam("id", "Session id."),
{ ...queryParam("path", "Absolute file path."), required: true },
],
responses: {
200: ok("Text.", { text: { type: "string" }, truncated: { type: "boolean" } }),
400: errs("EBADPATH."),
403: errs("EUNSUPPORTED or ESECRET."),
404: errs("NOT_FOUND / ENOTFOUND."),
415: errs("EBINARY."),
},
},
},
"/api/workflows/skill-usage": {
get: {
tags: ["Workflows"],
summary: "How much each skill and plugin is used",
operationId: "getSkillUsage",
description:
"Counts `Skill` PreToolUse events (top 30 skills; a `plugin:skill` name carries its plugin) and, per plugin, also calls to its MCP tools (`mcp__plugin_<plugin>__…`). Server mode: a developer's own sessions only.",
parameters: [queryParam("status", "Session status filter."), ...rangeParams],
responses: {
200: ok("Usage.", {
skills: {
type: "array",
items: {
type: "object",
properties: {
name: { type: "string" },
count: { type: "integer" },
sessions: { type: "integer" },
plugin: { type: "string", nullable: true },
},
},
},
plugins: {
type: "array",
items: {
type: "object",
properties: {
name: { type: "string" },
skills: { type: "integer" },
skillCalls: { type: "integer" },
mcpCalls: { type: "integer" },
},
},
},
}),
400: errs("INVALID_INPUT."),
500: errs("Query failed."),
},
},
},
"/api/alerts/rules/preview": {
post: {
tags: ["Alerts"],
summary: "What a rule would have fired",
operationId: "previewAlertRule",
description:
"Replays a rule config over the last `days` (1-90, default 7) of recorded history. Read-only: nothing is saved, fired or broadcast. `status_duration` cannot be replayed (`supported: false`).",
requestBody: json(
{
rule_type: { type: "string" },
config: { type: "object" },
days: { type: "integer", minimum: 1, maximum: 90 },
cooldown_seconds: { type: "integer", minimum: 0, description: "Default 300." },
},
["rule_type", "config"]
),
responses: {
200: ok("Preview.", {
supported: { type: "boolean" },
window_days: { type: "integer" },
fires: { type: "integer" },
sessions: { type: "integer" },
samples: { type: "array", items: { type: "object" }, maxItems: 10 },
periods: { type: "array", items: { type: "object" } },
}),
400: errs("INVALID_INPUT."),
},
},
},
"/api/hooks/ingest-batch": {
post: {
tags: ["Hooks"],
summary: "Push hook events from another machine",
operationId: "ingestHookBatch",
description:
"Authenticated only by `Authorization: Bearer <push token>` (Settings → Database → Push ingest; compared in constant time, never taken from a query string); 503 until a token exists. Each event goes through the same ingest as `POST /api/hooks/event`, its session tagged `push:<source>`; a pushed hook may extend only a session its own source created (EFOREIGN), its `transcript_path` is dropped, and it never touches a lane or a pane.",
requestBody: json(
{
source: { type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$" },
events: {
type: "array",
minItems: 1,
maxItems: 500,
items: {
type: "object",
required: ["hook_type", "data"],
properties: {
hook_type: { type: "string" },
data: { type: "object" },
tool: { type: "string" },
},
},
},
},
["source", "events"]
),
responses: {
200: ok("Per-event results.", {
ok: { type: "boolean" },
source: { type: "string", example: "push:laptop" },
stored: { type: "integer" },
results: { type: "array", items: { type: "object" } },
}),
400: errs("EBADSOURCE or EBADBATCH."),
401: errs("EUNAUTHORIZED."),
503: errs("EPUSHDISABLED."),
},
},
},
"/api/artifacts/{id}/installs": {
get: {
tags: ["Artifacts"],
summary: "Who installed an artifact",
operationId: "listArtifactInstalls",
description:
"Newest first (≤ 500). The owner, staff and local mode see every install, anyone else only their own; `paths` (where a skill was written) goes only to the installer and an admin. `unrecorded` counts installs from before this list was kept.",
parameters: [pathParam("id", "Artifact id.")],
responses: {
200: ok("Installs.", {
items: {
type: "array",
items: {
type: "object",
properties: {
user: { type: "string", nullable: true },
mine: { type: "boolean" },
at: { type: "string" },
replaced: { type: "boolean" },
paths: { type: "array", items: { type: "string" } },
},
},
},
unrecorded: { type: "integer" },
}),
404: errs("ENOTFOUND."),
},
},
},
"/api/artifacts/{id}/uninstall": {
post: {
tags: ["Artifacts"],
summary: "Take an installed skill or plugin out of the caller's accounts",
operationId: "uninstallArtifact",
description:
"A skill's folders are moved aside (never deleted); a plugin runs `claude plugin uninstall --scope user --keep-data` as the caller. Allowed while installs are off. Same-origin only.",
parameters: [pathParam("id", "Artifact id.")],
responses: {
200: ok("Removed.", {
uninstalled: { type: "string" },
paths: { type: "array", items: { type: "string" } },
}),
400: errs("EBADKIND / EBADPLUGIN."),
404: errs("ENOTFOUND."),
409: errs("ENOTINSTALLED or EUPGRADE."),
502: errs("EPLUGIN."),
},
},
},
};
module.exports = { tags, schemas, paths };