<CLAWBOO_HOME>/clawboo.db (default ~/.clawboo/clawboo.db); those routes serve and mutate local state and do not require the Gateway to be up. There are exceptions. The three /api/catalog/* routes touch no database at all. POST /api/exec-settings writes clawboo’s own row and then, for an OpenClaw agent only, writes the Gateway’s policy as well, so on that one runtime it needs the Gateway up. GET /api/exec-allowlist reads OpenClaw’s state database rather than clawboo’s, and POST /api/exec-allowlist/revoke goes to the Gateway for every runtime it accepts. All POST/PUT bodies are parsed by express.json({ limit: '2mb' }).
The order in
api/index.ts matters: /api/cost-records/summary, /api/exec-settings/all and /api/exec-allowlist/revoke are registered before their shorter prefixes so the two-segment paths are not swallowed.Routes
Cost records: /api/cost-records
Token-usage records, one row per accounted run. The POST handler computes USD from a built-in per-model pricing table (calculateCostUsd), and the summary route aggregates the last 30 days for the cost dashboard.
GET /api/cost-records
Lists cost records, newest first, capped at 500.
- Query params:
- Request body: none.
Responses
200 OK: the matching records (a costRecords row array):
500 Internal Server Error: a DB failure:
Example
POST /api/cost-records
Records one run’s token usage. The handler computes costUsd from the model name and token counts, then upserts a placeholder agents row (the cost_records.agentId foreign key requires the agent to exist) before inserting the record.
- Request body:
Pricing is a built-in table keyed by Claude model ids (opus / sonnet / haiku tiers), with a substring fallback and a
default of 15 per million input/output tokens. An unrecognized model is priced at the default rate.Responses
400 Bad Request: body is not an object:
400 Bad Request: a required field is missing (inputTokens / outputTokens are checked for null/undefined, so 0 passes):
200 OK: the record was inserted:
500 Internal Server Error: a DB failure:
Example
GET /api/cost-records/summary
Aggregates the last 30 days of cost records into dashboard totals, a per-agent breakdown (agent name joined from agents), and a 30-day time series with zero-filled empty days. Takes no parameters.
- Path/query params: none.
- Request body: none.
Responses
200 OK: the aggregation:
500 Internal Server Error: a DB failure:
Example
Chat history: /api/chat-history
Persists per-session chat transcripts in the chat_messages table. Each row stores a JSON-serialized TranscriptEntry; reads parse the JSON back, skipping any corrupt row.
Two different verbs end a conversation, and the difference matters. Reset context is what /reset and /new call: it ends what the model is carrying and writes a divider, and moves no message at all. Delete is what agent deletion calls: the conversation is destroyed. Nothing else removes a message.
GET returns the most recent page and a cursor for walking backwards, which is how a chat that is never cleared stays readable.
GET /api/chat-history
Loads a page of a session’s transcript entries, oldest first within the page. The page is the most RECENT one, and before walks backwards from there.
- Query params:
- Request body: none.
Responses
400 Bad Request: missing sessionKey:
200 OK: the parsed transcript entries (rows that fail JSON parse are dropped), plus the cursor for the page before this one:
500 Internal Server Error: a DB failure:
Example
POST /api/chat-history
Batch-inserts transcript entries for a session. Inserts are idempotent; each row carries the entry’s entryId and conflicts on the unique entry_id index do nothing. Entries without an entryId are skipped.
- Request body:
Responses
400 Bad Request: body is not an object:
400 Bad Request: sessionKey missing or entries not a non-empty array:
200 OK: inserted (idempotent on entryId; saved counts the entries received, not the rows actually written):
500 Internal Server Error: a DB failure:
Example
POST /api/chat-history/reset-context
Ends the model’s conversation on every listed session and writes one divider into the transcript. No existing message is touched: nothing is moved, re-keyed, or deleted. Also clears the native resume pointers for each key (the 1:1 pointer, or the per-team one for a team key) so the next turn starts without the earlier turns.
A team room passes every teammate’s session key and one noticeSessionKey, because the person is looking at a single merged timeline and should see one divider, not one per teammate.
- Request body:
Responses
400 Bad Request: no usable keys (sessionKeys absent, not an array, or holding no non-empty string):
400 Bad Request: the notice key is outside the list:
200 OK: the divider that was written, ready to append to the open transcript:
500 Internal Server Error: a DB failure:
Example
DELETE /api/chat-history
Destroys every message for a session. Used when an agent is deleted. To end a conversation without losing it, use the reset-context route above.
- Query params:
sessionKey(required). - Request body: none.
Responses
400 Bad Request: missing sessionKey:
200 OK: cleared:
500 Internal Server Error: a DB failure:
Example
Graph layout: /api/graph-layout
Persists Ghost Graph node positions in the graph_layouts table, keyed by the (name, gatewayUrl) unique index. name distinguishes scopes (e.g. atlas-radial, team-<id>, default).
GET /api/graph-layout
Loads saved positions for a layout. Note the query param is url, not gatewayUrl.
- Query params:
- Request body: none.
Responses
200 OK: the saved layout, or an empty positions map when nothing is stored:
This route never returns an error status. A miss returns
{ positions: {} }, and a thrown DB error is also caught and returned as { positions: {} } (HTTP 200).Example
POST /api/graph-layout
Upserts positions for a layout (conflict on (name, gatewayUrl) updates layoutData + updatedAt).
- Request body:
Responses
400 Bad Request: body is not an object:
400 Bad Request: missing gatewayUrl:
200 OK: upserted:
500 Internal Server Error: a DB failure:
Example
Personality: /api/personality
Stores per-agent personality slider values in the agents.personality_config column as a JSON wrapper { values, customText }. SQLite is the source of truth for slider values; the merged SOUL.md is written separately by the client.
GET /api/personality
Loads an agent’s stored personality values and optional custom text.
- Query params:
agentId(required). - Request body: none.
Responses
400 Bad Request: missing agentId:
200 OK: the stored values, or nulls when nothing is stored or the blob is corrupt:
500 Internal Server Error: a DB failure:
Example
POST /api/personality
Upserts an agent’s personality config. The handler ensures a placeholder agents row exists, then sets personality_config to JSON.stringify({ values, customText }). A blank/whitespace customText is stored as null.
- Request body:
Responses
400 Bad Request: body is not an object:
400 Bad Request: agentId or values missing:
200 OK: upserted:
500 Internal Server Error: a DB failure:
Example
Skills: /api/skills
Tracks skill installs in the skills table. The per-agent association lives in the row’s metadata.agentIds array, so a single skill row can be shared across agents. POST runs a supply-chain injection scan and blocks a flagged install with a 422.
GET /api/skills
Lists installed skills, newest first. With agentId, filters to rows whose metadata.agentIds includes that agent.
- Query params:
agentId(optional). - Request body: none.
Responses
200 OK: the skill rows:
500 Internal Server Error: a DB failure (note: skills: [] is still present):
Example
POST /api/skills
Adds a skill (a capability annotation) to an agent. Before recording anything, the handler runs evaluateInjection over the install blob (name + source + category + the raw body) on the exec surface, where a skill install is spawn-bound and every rule that fires is blocking. A blocking finding refuses the install with 422 and writes a blocked-install audit row; a clean install is also audited (the forensic trail). The audit row stores only {pattern, line, fingerprint} per finding; full excerpts stay in the HTTP response. On a clean scan, an existing skill row merges the agentId into metadata.agentIds; otherwise a new row is inserted.
- Request body:
Responses
400 Bad Request: body is not an object:
400 Bad Request: a required field is missing or not a non-empty string (or category is a non-string):
422 Unprocessable Entity: the injection scan found a destructive / exfil / injection / supply-chain pattern; the install is blocked and audited:
200 OK: installed (merged into an existing row, or a new row inserted):
500 Internal Server Error: a DB failure:
Example
DELETE /api/skills
Removes an agent from a skill’s metadata.agentIds. If that was the last agent, the skill row is deleted entirely; otherwise the row is kept with the agent removed.
- Query params:
id(skill id, required) andagentId(required). - Request body: none.
Responses
400 Bad Request: id or agentId missing:
200 OK: skill not found (idempotent no-op):
200 OK: the agent was the last holder; the row was deleted:
200 OK: the agent was removed but the row remains (other agents still hold it):
500 Internal Server Error: a DB failure:
Example
Marketplace catalog: /api/catalog/*
The only routes in this group backed by files rather than SQLite. Marketplace
content lives in catalog/, which is excluded from the npm tarball; the server
resolves it (local filesystem, then CLAWBOO_CATALOG_INDEX_URL, then a default
URL), verifies each pack bundle against the digest the index publishes, and
flattens the verified packs to entries. For /api/catalog/* specifically, the
browser therefore needs no integrity logic and no second origin. That is a
property of these three routes, not a project-wide rule.
The connector registry is deliberately not behind this seam. A catalog pack
leaves the tarball and arrives from a remote that can change after release, so a
runtime digest check is the only one available. The connector snapshot is itself
the shipped artifact, compiled in and served as a lazy chunk, so it is
content-addressed before publish instead: pnpm verify:connectors recomputes its
digest and fails CI on an edit that preserves shape. Each is checked at the point
where checking it means something, and moving the snapshot behind these routes
would trade an offline guarantee for integrity it already has.
These never fail closed. The built-in pack is compiled into the server and merged
unconditionally, so an unreachable or empty remote degrades the catalog rather than emptying it.
That matters because first-run onboarding renders its team picker with no “start from scratch”
escape hatch: an empty catalog is not a degraded browse experience, it is a first run with nothing
to click.
GET /api/catalog/index
Browse rows only, never prose. Roughly 275 KB for the 436 agents and 85 teams
this repo ships.
packs[] row with offline: true is the compiled seed rather than a fetched
pack.
GET /api/catalog/agents/:id
The agent’s document set, keyed by filename. 404 on an unknown id.
AGENTS.md and CLAWBOO.md are not here. They are synthesized per-deploy
from the team topology, and IDENTITY.md is rewritten with the agent’s final,
deduped name, so the deploy path overlays on this map rather than passing it
through.
GET /api/catalog/teams/:id
404 on an unknown id. See
the marketplace catalog reference for the pack
format and the verification rules.
Exec settings: /api/exec-settings
Stores per-agent execution permission settings in the agents.exec_config column as JSON. Read per agent, read all agents at once during fleet hydration, or upsert one agent. The two GETs report clawboo’s own record only. The POST also writes the Gateway’s policy for an OpenClaw agent, because that is the copy that decides whether a command is asked about.
GET /api/exec-settings
Loads one agent’s execution settings.
- Query params:
agentId(required). - Request body: none.
Responses
400 Bad Request: missing agentId:
200 OK: the parsed exec_config, or null when none is stored:
500 Internal Server Error: a DB failure:
Example
GET /api/exec-settings/all
Returns a map of every agent’s execAsk value. Rows without an exec_config, or with malformed JSON, or whose execAsk is not a string, are skipped. Used during fleet hydration.
- Path/query params: none.
- Request body: none.
Responses
200 OK: the per-agent execAsk map:
500 Internal Server Error: a DB failure:
Example
POST /api/exec-settings
Upserts one agent’s execution settings, and for an OpenClaw agent applies the same posture to the Gateway. The handler ensures a placeholder agents row exists and stores JSON.stringify(values) in exec_config, then reads that row’s runtime back and, when it is openclaw and the row carries an OpenClaw agent id, writes the Gateway’s exec-approval policy under OpenClaw’s id rather than clawboo’s. The local write always runs first, so a Gateway refusal never loses what you typed.
The Gateway half used to run in the browser, behind a connection check with the success message shown either way, so a tab with no Gateway connection reported success while the Boo went on running every command unasked. Doing it in the server takes the tab out of the path and reports a refusal instead of swallowing it.
- Request body:
execAsk must be 'off' (Run Freely), 'on-miss' (Ask for Unknown) or 'always' (Always Ask). execSecurity is stored in exec_config and is not sent to the Gateway by this route.
Responses
400 Bad Request: body is not an object:
400 Bad Request: agentId or values missing:
200 OK: stored locally, and the Gateway accepted the posture:
200 OK: stored locally, with no Gateway policy to write. This is the answer for every non-OpenClaw runtime, and also for an openclaw row with no sourceAgentId recorded. For those the local record is the whole setting:
400 Bad Request: an execAsk value the Gateway has no meaning for. This check sits after the runtime branch, so it fires on the OpenClaw path only: the same unrecognised string on a native, claude-code, codex or hermes agent is stored verbatim and answered 200 with gateway: "not-applicable":
502 Bad Gateway: the value is in clawboo’s record and the Gateway refused it, so what the Boo is actually under did not change. Note the shape: ok is false and there is a savedLocally flag, because a partial write is neither a success nor a no-op:
500 Internal Server Error: a DB failure:
Example
Standing exec grants: /api/exec-allowlist
What an OpenClaw Boo may already run without being asked, and the one way to take it back. A grant of this kind is minted by an operator answering Always to a command prompt, and these two routes are the read and the revoke behind the Permissions tab’s “Commands this Boo can run without asking” card.
Both routes are OpenClaw-only. No other runtime keeps a standing-grant store, and answering an empty list for a runtime that has no store would imply one exists and happens to be empty.
The read never calls the Gateway. clawboo treats exec.approvals.get as a write rather than a read: on clawboo’s reading of OpenClaw, that call re-serialises the stored policy and writes it back, so issuing it against a document that cannot be parsed would replace the whole fleet’s policy with a fail-closed default. Opening a permissions panel must not be able to destroy a policy. So clawboo instead opens OpenClaw’s state database read-only at <state dir>/state/openclaw.sqlite and reads the stored policy row. The state directory is OPENCLAW_STATE_DIR, then MOLTBOT_STATE_DIR, then CLAWDBOT_STATE_DIR; with none of those set it is ~/.openclaw when that directory exists, otherwise the first of ~/.clawdbot and ~/.moltbot that does, otherwise ~/.openclaw. Only the revoke talks to the Gateway.
GET /api/exec-allowlist
Reads one Boo’s standing grants out of OpenClaw’s stored policy, plus the fleet-wide agents['*'] bucket, which clawboo treats as live for this Boo and as belonging to every other one too. The two buckets are returned separately and are never merged: wildcard rows count against this Boo and are not this Boo’s to revoke.
socket.token sits in the same stored blob and is never returned.
- Query params:
agentId(required, clawboo’s row id; the handler resolves it to OpenClaw’s id itself). - Request body: none.
Responses
400 Bad Request: no agentId:
404 Not Found: no agents row with that id:
200 OK, state: 'ok': the policy was read. entries is this Boo’s own bucket, the only rows a revoke may touch:
200 OK, state: 'absent': there is no policy document on this machine at all, so nothing is granted to anyone:
200 OK, state: 'not-applicable': the agent is not on the openclaw runtime, or its row carries no OpenClaw id, so there is no standing-grant store to read:
502 Bad Gateway, state: 'unreadable': the document exists and could not be read. The counts come from the last successful save and are document-wide, covering every agent in the file rather than this Boo:
This branch carries no
entries key at all, deliberately. A client that destructures a default of [] would otherwise paint an empty list over a document whose grants are still being enforced. “Could not read” also covers a clawboo server that is simply down, an unrecognised response shape, and any other transport failure, so it is not on its own proof of a corrupt database.500 Internal Server Error: any other failure, redacted:
Example
POST /api/exec-allowlist/revoke
Removes named rows from one Boo’s own bucket. There is no revoke call to make: the two methods clawboo has are exec.approvals.get and exec.approvals.set, so removing one row means rewriting the fleet’s entire permissions document under a compare-and-swap, with every other agent’s policy riding along in the same payload. Rows are filtered rather than rebuilt, the Boo’s own security and ask survive the rewrite, and an emptied bucket is kept rather than deleted so the Boo does not fall back to the document defaults.
Success is proved against the Gateway’s own post-write reply rather than against what clawboo sent. Re-reading to confirm is not an option here, for the reason above: exec.approvals.get is itself a write.
This route sits on the sensitive rate-limit tier, 60 requests a minute per client address, rather than the router-wide general one.
- Request body:
key values back exactly as the GET returned them. A key is a content fingerprint built from the row’s pattern, argPattern and source joined by NUL characters, not an id, so it should be echoed rather than constructed. A mint that produced a node-marker companion should be revoked with both rows in the same call: clawboo reads that companion as load-bearing for the node-host path, so leaving it behind leaves a row that grants nothing on its own.
Responses
400 Bad Request: no agentId, a keys array that is missing, empty, longer than 200, or containing a non-string or empty entry:
404 Not Found: no agents row with that id:
400 Bad Request: the agent is not on the openclaw runtime, or its row carries no OpenClaw id:
200 OK, outcome revoked: the rows are gone, confirmed against the Gateway’s post-write document. remaining is how many rows this Boo’s bucket still holds:
409 Conflict, outcome already-absent: nothing matched, so nothing was written. Not an error, and specifically not a success:
409 Conflict, outcome blocked-wildcard: at least one key also lives in the fleet-wide agents['*'] bucket, which clawboo expects enforcement to union ahead of this Boo’s own, so removing the per-Boo copy would leave the command granted while reporting a clean revoke. Nothing was written, and the refusal is all-or-nothing: one duplicated key aborts every key in the same call. keys names the offenders:
409 Conflict, outcome no-such-agent: the document has no bucket for this Boo at all, so there was nothing to change:
502 Bad Gateway, outcome not-verified: the write went through and the Gateway’s reply still lists those rows, or came back with no post-state to check against:
429 Too Many Requests: the sensitive ceiling:
502 Bad Gateway: the call to the Gateway threw. Its own words are passed through, redacted:
Example
Fleet summary: /api/fleet/summary
A read-only aggregation that joins existing tables/streams into one overview; it never recomputes or re-derives state. It counts live (non-archived) agents per runtime, gets each runtime’s class + health from the adapters and the OpenClaw source, rolls up the last 24h of board tasks and verification verdicts, and counts governance budgets. The per-runtime tile loop is runtime-id-agnostic (open-set runtime strings).
- Path/query params: none.
- Request body: none.
Responses
200 OK: the overview:
A runtime with no agent rows still appears (with zero counts) if an adapter or the OpenClaw source reports for it; OpenClaw is always
connected-substrate and its healthOk reflects whether the server-side source connection is connected.500 Internal Server Error: a failure building the summary:
Example
Boo Zero context: /api/boo-zero/*
Boo Zero is the universal team leader. These routes store the markdown briefs it reads (per-team and global), a Clawboo-side display-name override, and the runtime-neutral leader override that decides which agent is Boo Zero at all. Per-team briefs live in the boo_zero_team_briefs table (FK-cascades on team delete); the global brief, the display name, and the leader override live in the settings key/value table.
A missing brief returns
null content, not a 404; the UI then falls back to a client-side default brief. Likewise a missing display name returns name: null so the caller falls back to the Gateway-side agent name.GET /api/boo-zero/team-briefs/:teamId
Loads a team’s Boo Zero brief.
- Path params:
teamId(required). - Request body: none.
Responses
400 Bad Request: missing teamId:
200 OK: the stored brief, or nulls when none exists:
500 Internal Server Error: a DB failure:
Example
PUT /api/boo-zero/team-briefs/:teamId
Upserts a team’s brief (conflict on teamId updates content + updatedAt).
- Path params:
teamId(required). - Request body:
Responses
400 Bad Request: missing teamId:
400 Bad Request: body missing a string content:
200 OK: upserted:
500 Internal Server Error: a DB failure:
Example
DELETE /api/boo-zero/team-briefs/:teamId
Removes a team’s brief. Idempotent; deleting a non-existent brief is a no-op. (The FK cascade already cleans briefs up when the team itself is deleted; this route is for an explicit user action.)
- Path params:
teamId(required). - Request body: none.
Responses
400 Bad Request: missing teamId:
200 OK: removed (or already absent):
500 Internal Server Error: a DB failure:
Example
GET /api/boo-zero/global-brief
Loads the global Boo Zero brief from the settings key boo-zero:global-brief.
- Path/query params: none.
- Request body: none.
Responses
200 OK: the stored brief, or nulls when unset:
updatedAt is always null on this route; the global brief is stored in the settings KV table, and the handler does not re-query the row’s timestamp.500 Internal Server Error: a DB failure:
Example
PUT /api/boo-zero/global-brief
Sets the global Boo Zero brief.
- Request body:
Responses
400 Bad Request: body missing a string content:
200 OK: saved (updatedAt is Date.now()):
500 Internal Server Error: a DB failure:
Example
GET /api/boo-zero/display-name/:agentId
Loads the Clawboo-side display-name override for Boo Zero, keyed by agent id, from the settings key boo-zero:display-name:<agentId>.
- Path params:
agentId(required). - Request body: none.
Responses
400 Bad Request: missing agentId:
200 OK: the override, or null when unset:
500 Internal Server Error: a DB failure:
Example
PUT /api/boo-zero/display-name/:agentId
Sets the display-name override. The value is trimmed and truncated to 80 chars; an empty string clears the override.
- Path params:
agentId(required). - Request body:
Responses
400 Bad Request: missing agentId:
400 Bad Request: body missing a string name:
200 OK: saved (returns the trimmed/truncated value actually stored):
500 Internal Server Error: a DB failure:
Example
GET /api/boo-zero/override
Reads the runtime-neutral leader override stored in the settings key boo-zero:agent-id, alongside the Boo Zero that resolveBooZero currently lands on and which rung of its chain (override → native → OpenClaw) produced it.
- Request body: none.
Responses
200 OK: the stored override (null when unset or cleared), the effective leader, and the tier:
tier is derived from the stored setting first, so a non-null overrideAgentId always reports 'override'.
500 Internal Server Error: a DB failure:
Example
POST /api/boo-zero/override
Sets or clears the override. Any runtime is legal by design (the resolver does no runtime check), which is what lets a non-native agent lead every team in a mixed install. Setting validates that the agent row exists and is not archived, so a stale id is never stored; clearing writes an empty value, restoring the default override → native → OpenClaw chain.
- Request body:
Responses
400 Bad Request: the body is not an object, or has no agentId key:
400 Bad Request: agentId is neither null nor a non-empty string:
404 Not Found: the id does not match a live agent row:
200 OK: stored (or cleared), with the re-resolved effective leader:
500 Internal Server Error: a DB failure:
Example
Connectors: /api/connectors
A connector is a third-party MCP server the operator connects to clawboo, which then runs with credentials the operator supplies: an API key pasted into the vault, an OAuth sign-in, or a launch argument such as the folder a filesystem server is allowed to see. Connectors come from the committed catalog (@clawboo/connector-catalog) or from custom entries the operator registers themselves, and the three routes below are the read-only surface the panel polls: what is running, what is already configured, and what real paths exist on this machine.
All three sit on the general rate-limit tier, 3000 requests a minute per client address, above which the limiter answers 429 with { "error": "Too many requests. Wait a moment and retry." }. None of them requires a credential to call, and none returns a secret value: the configured route reports presence only.
GET /api/connectors
Lists the connectors that are live right now, as held in the supervisor’s in-memory map. A connector that is defined but not connected does not appear.
- Query params: none.
- Request body: none.
Responses
200 OK: the live connectors:
toolCount is the length of the connector’s descriptor list and tools is those descriptors’ names, each already namespaced by slug. skipped carries the tools that could not be represented, each with the reason it was dropped, so a missing tool is visible rather than silent; the reasons seen here are the namespacing refusal, duplicate-name, and one synthetic entry named (inventory) with reason tool-list-truncated, which marks an inventory the server could not read to the end.
The live connector’s resolved command is held in memory but is not part of this response.
500 Internal Server Error: any thrown error, stringified and passed through the redactor:
Example
GET /api/connectors/configured
Answers, in one request for the whole shelf, which connectors already have everything they asked for. It walks every definition, the committed catalog plus the operator’s custom entries, and returns two separate lists of slugs.
- Query params: none.
- Request body: none.
Responses
200 OK: two slug lists:
slugs holds every connector whose configuration is satisfied: every required credential stored, the launch argument satisfied, and, for a remote connector that is not bearer-authenticated, an OAuth authorization on file. A bearer remote is answered by its credential instead, so it is never held back waiting on a sign-in it does not run. supplied holds every connector a person actually handed something to, meaning at least one credential is present or a launch argument is stored. The two differ: a connector that declares no inputs and takes no argument is satisfied the moment it exists, so it appears in slugs but not in supplied.
No value, token, or per-connector detail is returned here. Presence only.
500 Internal Server Error: any thrown error, stringified and passed through the redactor:
Example
GET /api/connectors/path-suggestions
Returns real, server-verified filesystem paths to offer as chips for a connector that takes a path argument. Every suggestion is stat’d before it is offered, so a chip that comes back exists on this machine right now, and the list can legitimately be empty. The lookup uses the committed catalog only, so a custom connector is not resolved here.
- Query params:
- Request body: none.
sqlite the handler does a depth-2 walk from process.cwd(), skipping dotted entries and node_modules and never following symlinks out of the tree, gathering files matching .db, .sqlite, or .sqlite3 and stopping the walk once 25 candidates are in hand, then sorting them and offering the first five, each labelled with its basename and re-checked as a file. For any other qualifying slug it offers, in order, the working directory (labelled Where clawboo runs), then Documents, Desktop, and Downloads under the home directory, keeping only the ones that exist and are directories. Duplicate paths are dropped, and the response is capped at five suggestions either way.
Responses
200 OK: the verified suggestions:
404 Not Found: the slug is missing, is not a catalog connector, or names one that declares no userArgument:
500 Internal Server Error: any thrown error, stringified and passed through the redactor:
Example
POST /api/connectors/connect
Connects one catalog or custom connector, spawning its child process (stdio) or opening its remote session (streamable HTTP), and returns the tools it advertised. Sits on the sensitive rate-limit tier (60 requests per minute per client address), since one request spawns a process off the back of it.
Before it will connect anything, the handler re-evaluates the same refusal predicate the browser renders, server-side, against the credential vault and the settings store. A community connector is refused outright. A connector that declares REQUIRED credentials needs those values stored first (via PUT /api/connectors/:slug/config); an optional one left blank does not block the connect, matching credentialsSatisfied, which asks !c.required || c.present of each, a connector that declares a launch argument needs that path supplied, and a remote connector needs either a completed OAuth sign-in or, when its auth kind is bearer, a stored token. Anything still missing answers 422 rather than spawning. For an OAuth remote the access token is resolved before the check, which refreshes a refreshable but expired token; a bearer remote skips the sign-in machinery entirely and is answered by its credential.
Connecting a slug that is already live returns the existing session rather than starting a second child, and a request that arrives while a connect for the same slug is still in flight joins that attempt instead of spawning alongside it.
- Query params: none.
- Request body:
Responses
200 OK: the connector came up:
400 Bad Request: the body failed schema validation:
404 Not Found: no catalog or custom connector carries that slug:
422 Unprocessable Entity: the server refuses to connect it, with a machine-readable reason and the human copy for it. error is CONNECT_REFUSAL_COPY[reason]:
502 Bad Gateway: the spawn or handshake failed. Connection-level failures that never reached the other end are retried first, up to three attempts with a doubling delay; a request that arrived and was refused is not retried. error is a translated sentence naming the connector and the likely obstacle (missing npx or uvx, a Node version too old, an unpublished package or version, a rejected key, a missing scope, a timeout, an unreachable host, a missing file), falling back to <Name> did not start. when nothing matches. detail carries the original text, redacted and trimmed:
500.
Example
POST /api/connectors/:slug/disconnect
Closes the live connector for a slug and records the operator’s intent, so boot restore leaves it down instead of bringing it back. Sits on the sensitive rate-limit tier.
Intent is recorded first, before the teardown runs, so a 404 from this route has still written desiredState: 'disconnected' onto the connector’s row when one exists. If a connect for the same slug is still in flight, the handler waits for that attempt to settle before deciding whether anything is live.
- Path params:
- Query params: none.
- Request body: none.
Responses
200 OK: the connector was closed:
404 Not Found: nothing was live under that slug:
500 Internal Server Error: the teardown threw, with the message redacted:
Example
POST /api/connectors/:slug/authorize
Starts an interactive OAuth sign-in for a remote connector and returns the URL the operator has to open. It does not open anything itself, because the server may not be on the machine with the browser. The slug is resolved against the committed catalog first and then the operator’s own custom connectors, so a custom entry takes the same code path as a catalog one. Sits on the sensitive rate-limit tier (60 requests per minute per client address), since one request opens a sign-in at a third party.
No operator-supplied credential is involved. Clawboo discovers the provider’s authorization server and registers itself per install via dynamic client registration, and the redirect lands on a loopback http://127.0.0.1:<port>/callback listener that is already bound before this route answers, never on a route under /api. The port is ephemeral, except that the port a previous registration was pinned to is tried first so the registration can be reused. (The broker route POST /api/connectors/composio/apps/:slug/authorize is a different endpoint and does need the operator’s Composio API key, which it answers 409 without.)
Starting a sign-in cancels any earlier pending sign-in for the same slug and closes its listener, and a slower concurrent attempt that finishes after another one claimed the slug throws rather than publishing over it, so at most one is ever pending per connector.
- Path params:
- Query params: none.
- Request body: none. The handler reads no body.
Responses
200 OK: the URL to open:
400 Bad Request: the connector is local, so there is nothing to sign in to:
404 Not Found: no catalog or custom connector has that slug:
429 Too Many Requests: the sensitive ceiling:
502 Bad Gateway: every other failure, because the fault is almost always at the provider’s discovery or registration endpoint rather than in this server. There is no 500 here: the whole handler sits in one try, and its catch always answers 502. The start step (resource-metadata discovery, authorization-server discovery, the listener bind, and dynamic registration) is retried up to 3 attempts with a doubling 300 ms backoff, but only for failures that never reached the other end. Two message shapes, both naming the connector:
<url> did not name an authorization server), and what a concurrent sign-in that claimed the slug first produces (another sign-in for this connector started first).
Example
POST /api/connectors/:slug/authorize/await
A long poll that blocks until the sign-in started by POST /api/connectors/:slug/authorize finishes. Separate from starting it so the browser can open the authorize URL first and then wait. On the general rate-limit tier, not the sensitive one. No credential is involved.
The handler imposes no timeout of its own: it awaits the pending flow’s completion promise, and the bound on how long that can take belongs to the loopback listener, which gives up 5 minutes after the authorize call answered. So the request returns as soon as the flow settles, at the latest about 5 minutes after that, and an operator who abandons the provider tab gets a 400 rather than a request that hangs forever.
200 means the authorization code was exchanged and the tokens were stored, not merely that the redirect arrived: the completion promise covers the token exchange and the save. Everything else, including the timeout, is a 400.
The flow’s completion also removes its pending entry, so calling this a second time after the same sign-in has settled reports that no sign-in is in progress. Two callers awaiting the same in-flight sign-in both receive the same answer.
- Path params:
- Query params: none.
- Request body: none. The handler reads no body.
Responses
200 OK: the tokens are stored:
400 Bad Request: the sign-in did not complete. The body carries the failure message, passed through redactValue(String(err)), so it keeps the Error: prefix:
A failure inside the token exchange surfaces here too, carrying that exchange’s own message. So does any other throw, since the whole handler sits in one
try whose catch always answers 400.
429 Too Many Requests: the general ceiling:
Example
DELETE /api/connectors/:slug/authorize
Signs the connector out: stops it if it is running, then forgets its stored client registration and its tokens, which live in the vault under connector-oauth-client:<slug> and connector-oauth-tokens:<slug>. The teardown happens first, deliberately, because a live session is holding a token that is about to be deleted. That teardown also records the connector’s desired state as disconnected, so boot restore does not bring it back, and it waits for any in-flight connect attempt to settle before tearing down, so this can take as long as a connect does. Sits on the sensitive rate-limit tier (60 requests per minute per client address).
No definition lookup happens, so an unknown slug is not a 404: the route is idempotent and answers 200 whether or not anything was stored or running. It clears credentials only, and does not cancel a sign-in that is currently in flight.
- Path params:
- Query params: none.
- Request body: none.
Responses
200 OK: the connection is stopped and the stored client and tokens are deleted:
429 Too Many Requests: the sensitive ceiling:
500 Internal Server Error: the disconnect or the secret deletion threw, with the message redacted:
Example
GET /api/connectors/:slug/config
Reports everything an operator must supply before this connector can run, and whether they have. The slug is resolved against the committed catalog first and then the operator’s own custom connectors, so both kinds answer here.
Secret values are never returned. Each declared credential comes back as a present boolean only, while the launch argument comes back in full, because checking which folder or file a connector was handed is the reason for asking.
- Path params:
- Request body: none.
Responses
200 OK: the connector’s configuration state:
argument is null when nothing is stored for this connector, and also when the stored value is empty.
authorized is the OAuth question, and it is only asked of a streamable-http connector whose auth kind is not bearer; every other connector reports true. A bearer remote is answered by its credential rather than by the OAuth store, which is why it is excluded here.
satisfied is true when every required credential is stored (optional ones never block), the launch argument requirement is met, and authorized is true. Only a connector flagged requiresUserArgument needs a non-empty argument; one that merely declares a userArgument is satisfied without it.
404 Not Found: no catalog or custom connector carries that slug:
500 Internal Server Error: an unexpected failure, with the message passed through the redactor:
Example
PUT /api/connectors/:slug/config
Stores the credentials and the launch argument an operator supplies for one connector. Credential values go to the local secrets vault under a slot namespaced by connector, connector:<slug>:<KEY>, and the launch argument is stored as a setting outside the vault. These are the operator’s own credentials for the connector itself, entered once and explicitly, rather than a broker key such as a Composio API key.
This route sits on the sensitive rate-limit tier in index.ts, alongside the other writes that hand out access.
The slug is resolved before the body is parsed, so an unknown slug answers 404 even when the body is also invalid. After that, the whole body is validated before anything is written, so a rejected request never persists half of itself.
Each supplied credential value passes through the same paste cleaner the UI field runs. It trims surrounding whitespace, strips a leading auth scheme (Bearer, Token or Basic, case-insensitive), and unwraps matched surrounding quotes (", ' or a backtick), repeating so a nested pair such as '"abc"' unwraps and a scheme hidden inside quotes is still removed. A value that cleans down to the empty string clears that credential instead of storing it.
- Path params:
- Request body:
Both fields are optional, so an empty object is a valid body and returns the current state unchanged.
Every key in
values must be declared by the connector’s own auth spec. An undeclared key is refused, so this route cannot be used as a general write into the vault under a connector’s name.
Responses
200 OK: the configuration state after the write, the same shape GET returns and still presence-only for credentials, so a secret handed to this route cannot be read back from it:
400 Bad Request: the body failed schema validation, which includes a values key that is not a valid env var name and a value over the length cap:
400 Bad Request: a key in values is a valid env var name but is not declared by this connector:
404 Not Found: no catalog or custom connector carries that slug:
500 Internal Server Error: an unexpected failure, with the message passed through the redactor:
Example
Composio, the broker routes
Composio is the broker clawboo uses for apps it cannot sign into directly. These four routes are about an account at Composio and which of its apps the operator has linked, not about a connector clawboo spawns or holds a session to, so they live in their own handler file,apps/web/server/api/composio.ts.
The credential behind them is a Composio API key that the operator supplies. It
is the project key, the one starting with ak_ on the Composio project settings
page, and clawboo takes it through PUT /api/connectors/composio/key and holds
it in the encrypted runtime vault under the slot name COMPOSIO_API_KEY. Only
POST .../apps/:slug/authorize refuses to run without a stored key; the status
read and the delete both answer when no key is held. The key itself never comes
back out over HTTP: the only fact any of these responses reports about it is the
boolean hasKey.
The three write routes sit on the sensitive rate-limit tier; the status read
stays on the general tier because the connector panel polls it.
GET /api/connectors/composio
Whether a key is stored, and which brokered apps are linked. Reading it refreshes
the connected-apps cache when that cache is older than 60 seconds, waiting for at
most one refresh, and a second reader arriving during a refresh waits on the same
one rather than starting another.
- Query params: none.
- Request body: none.
Responses
200 OK, no key stored:
200 OK, a key is stored. connected holds clawboo slugs for the brokered
apps with an active connected account, known is false until a refresh has
succeeded at least once, so a failed read with nothing cached returns
known: false with an empty connected rather than claiming nothing is linked,
and keyRejected is true when the last call made with the stored key was refused
with a 401 or 403:
502 Bad Gateway: reading the connected apps threw. The appended message is
redacted and truncated to 200 characters:
Example
PUT /api/connectors/composio/key
Stores the operator’s Composio API key. Sensitive rate-limit tier.
The paste is unwrapped first, so COMPOSIO_API_KEY=ak_..., an export line and a
quoted value all work, then the key is tried against Composio before anything is
written. A key Composio refuses is not stored. A key that could not be checked
because Composio was unreachable is stored, and the response says it was not
verified. Storing a key also drops the cached connected-apps answer, which
described the previous key’s account.
- Query params: none.
- Request body:
Responses
200 OK: the key was stored. verified is true only when Composio answered
and accepted it, false when Composio could not be reached:
400 Bad Request: the body carried no usable apiKey string:
400 Bad Request: the paste did not read as a project key, which is ak_
followed by at least ten more characters from A-Za-z0-9_-. The message is one
of Paste your Composio key. for an empty value, That is a Composio login key. The one needed here starts with ak_ and is on your project settings page. for a
uak_ value, or That does not look like a Composio key. It starts with ak_.
for anything else:
400 Bad Request: Composio answered and refused the key, so nothing was
stored:
500 Internal Server Error: an unexpected failure, redacted and truncated to
200 characters:
Example
DELETE /api/connectors/composio/key
Forgets the stored key and drops the cached list of connected apps. The
per-app grants stay at Composio; this only removes clawboo’s ability to reach
them. Succeeds whether or not a key was held. Sensitive rate-limit tier.
- Query params: none.
- Request body: none.
Responses
200 OK:
500 Internal Server Error: clearing the vault slot threw. The message is
redacted and truncated to 200 characters:
Example
POST /api/connectors/composio/apps/:slug/authorize
Starts linking one brokered app. The response carries Composio’s hosted consent
URL for the browser to open; the provider’s tokens are exchanged at Composio and
never reach this server. Sensitive rate-limit tier.
The stored key is checked before the slug is, so a request naming an unknown app
while no key is held answers 409, not 404. Past that, the route refreshes the
connected set before acting, so authorizing an app that is already linked returns
without minting a second consent flow. A successful authorization drops the cache
so the next status read cannot answer from before it.
The connection is created against a fixed local user id, clawboo-local, so one
install is one Composio user.
- Path params:
- Request body: none.
Responses
409 Conflict: no Composio key is stored. Checked first, before the slug:
404 Not Found: no brokered app carries that slug:
200 OK: the app was already linked, so nothing was started:
200 OK: a consent flow was opened. Open url in a browser:
502 Bad Gateway: Composio refused or failed the authorization, or answered
successfully but without a redirect URL, which is treated as a failure rather
than a connection. In the second case the trailing detail is absent:
500 Internal Server Error: an unexpected failure, redacted and truncated to
200 characters:
Example
connectors:custom settings key, not in the committed catalog, and they are returned with provenance: 'custom'. clawboo has not run or inspected them and vouches for nothing about them: toDefinition deliberately declares the trifecta at its most permissive (readsPrivateData, ingestsUntrustedContent and canEgress all true), an egressAllow of ['*'], and marks every declared auth input secret: true, because a value clawboo cannot vet is treated as a credential rather than as configuration.
GET /api/connectors/custom
Lists the operator’s own connector entries, converted into the same shape as a catalog entry so nothing downstream has to special-case them. No pagination, no filtering, and the order is storage order.
- Query params: none.
- Request body: none.
Responses
200 OK: every custom entry, in catalog shape:
auth is { kind: 'none', inputs: [] } whenever the stored entry declared no authInputs. An input whose stored description is empty comes back as 'Required by this server.'.
500 Internal Server Error: a read failure, with the message passed through redactValue:
slug, a string command or an array args is dropped and the rest are still returned.
Example
POST /api/connectors/custom
Creates or replaces a custom connector definition. Sits on the SENSITIVE rate-limit tier, 60 requests a minute, because the command and args it stores become a real child process later. Saving a slug that already exists as a custom entry replaces it and moves it to the end of the stored list.
This route takes no secret values. authInputs declares only the environment-variable NAMES the server needs, so the operator is actually asked for them; the values themselves are supplied afterwards through PUT /api/connectors/:slug/config and live in the vault.
- Query params: none.
- Request body: validated by
createCustomConnectorBody.
Responses
200 OK: the entry was stored:
400 Bad Request: the body failed schema validation. details is the zod flatten() shape:
409 Conflict: the slug is already a committed catalog connector. Shadowing one would silently replace an entry clawboo vouches for with a command it knows nothing about:
500 Internal Server Error: a write failure, with the message passed through redactValue:
Example
DELETE /api/connectors/custom/:slug
Removes a custom connector definition. Sits on the SENSITIVE rate-limit tier, 60 requests a minute.
The handler disconnects FIRST and deletes second: removing the definition of something still running would orphan the child process with nothing left that knows how to stop it. The disconnect runs for connectorInstanceId(slug) on every call and its result is ignored, returning false when nothing is live, so the 404 below means only that no stored definition carried that slug. The slug is read straight from the path with no shape validation on this route.
- Path params:
slug, the custom connector’s slug. - Query params: none.
- Request body: none.
Responses
200 OK: the definition was removed:
404 Not Found: no stored custom connector had that slug. The disconnect has already run by this point:
500 Internal Server Error: the disconnect or the write threw, with the message passed through redactValue:
Example
Grants: /api/grants
A grant is one row in capability_grants saying that a subject (an agent, a team, or everything) may use a capability (a connector, a tool, or a skill) in a given mode, under a given approval policy, and it is the same record the broker’s gate reads and the Ghost Graph draws as an edge. Grants carry an origin: owner rows record what a runtime’s own config already attaches, while operator rows are deliberate human shares, and only the latter are drawn as an edge with a Detach control.
All four routes are registered in apps/web/server/api/index.ts on the general rate-limit tier, not the sensitive one, so they inherit the router-wide ceiling of 3000 requests per minute per client address and return 429 Too Many Requests with { "error": "Too many requests. Wait a moment and retry." } above it. None of them needs an operator-supplied credential: they read and write the local database only, and never call Composio or any remote provider.
Every route that returns a grant returns the same object:
mode reads as read, an unrecognised state reads as suspended, and an unrecognised approvalPolicy reads as always. An unparseable toolAllow column reads as [] and an unparseable toolDeny reads as ['*'], both failing closed.
GET /api/grants
Lists grants, newest first by grantedAt, with no cap. Optionally narrowed to one subject.
- Query params:
- Request body: none.
Responses
200 OK: the matching grants:
500 Internal Server Error: a DB failure, with the message passed through the redactor:
Example
POST /api/grants
Creates a grant, or widens or narrows the existing grant for the same identity. This is an upsert keyed on the composite grant_key of subject and capability, not an insert, so re-sharing the same connector with the same subject updates the one row and sets its state back to active, clearing revokedAt and revokedReason. An update also resets grantedAt to now, which moves the row to the top of the GET /api/grants ordering. It always sends origin: 'operator', which promotes an existing owner row one way to operator, and it sends no grantedBy at all rather than fabricating an actor, because this server has no caller identity on any state-changing route: a newly inserted row therefore records grantedBy: null, and an update leaves whatever was stored alone.
- Query params: none.
- Request body: JSON, validated by
createGrantBodyinpackages/db/src/grants/schemas.ts.
Responses
200 OK: the inserted or updated grant:
400 Bad Request: the body failed schema validation. details is a zod v3 flatten() result, and the two cross-field rules (“one of connectorId or capabilityId is required” and “subjectId is required unless subjectKind is global”) carry no path, so they land in formErrors:
500 Internal Server Error: a DB failure, redacted:
Example
POST /api/grants/:id/revoke
Revokes a grant and cascade-deletes its standing approval rules, so a remembered “Always” cannot outlive the grant it was recorded against. The two halves are gated differently. The state change applies only to a row that is currently active: revoking a grant in any other state, proposed, suspended, revoked, or expired, leaves the stored row untouched and still answers 200 OK with that row, so the response is the grant’s current state rather than a confirmation that this call changed it. The standing-rule delete is not state-gated, so it runs for the given id whatever state the grant is in.
- Path params:
id, the grant id. - Request body: optional, and the shipped Detach caller sends none. When present it is parsed by
revokeGrantBody.
Responses
200 OK: the grant as it now stands:
404 Not Found: no grant has that id:
500 Internal Server Error: a DB failure, redacted:
Example
POST /api/grants/:id/resume
Undoes a revoke, but only inside a bounded window: RESUME_WINDOW_MS is 15 seconds, measured from revokedAt, which covers the shipped 8-second Undo toast. It restores state: 'active' and clears revokedAt and revokedReason, but it does not restore the standing rules the revoke deleted. A grant that is already active is returned as-is with 200 OK. A proposed, suspended, or expired grant is not resumable here, and because the row does exist the handler answers 409 Conflict for it, the same status a closed undo window gets.
- Path params:
id, the grant id. - Request body: none. Any body sent is ignored.
Responses
200 OK: the resumed, or already active, grant:
409 Conflict: the row exists but could not be resumed, either because more than 15 seconds have passed since the revoke, because the row is revoked with a null revokedAt, or because its state is not resumable:
404 Not Found: no grant has that id:
500 Internal Server Error: a DB failure, redacted:
Example
Error envelope
Every error response in this group is the standard envelope{ error: string }, except the skills routes (and the graph-layout POST), which use { ok: false, error: string }. The skills GET 500 additionally carries skills: [], and the skills POST 422 carries findings: InjectionFinding[]. The graph-layout GET never returns an error status; a miss or a thrown error both yield { positions: {} } (HTTP 200). The two exec-permission surfaces carry more than the envelope on purpose, because a half-applied permission change is neither a success nor a no-op: POST /api/exec-settings answers a Gateway refusal with { ok: false, savedLocally: true, error }, GET /api/exec-allowlist answers its 502 with a state of unreadable plus the document-wide counts and no entries key, and POST /api/exec-allowlist/revoke returns its outcome (and, where there is one, the offending keys) alongside error on every non-200.
See also
- Cost dashboard + budgets, the UI over cost records
- Governance API, budgets and the budget kill-switch
- Agents API, the agent registry that
agentIdreferences - Teams API, teams, rules, and team-chat (
teamIdreferences) - Boo Zero, briefs, rules, and display name in the UI
- Using the Ghost Graph, what
graph-layoutpositions - Database schema,
cost_records,chat_messages,graph_layouts,skills,boo_zero_team_briefs,agents - REST API overview