/v1/* and /mcp to agents, and the control plane serves /api/v1/* to operators.
This reference is written from the FastAPI route definitions and the frozen contracts in
packages/proto. There is no committed OpenAPI document for the control plane, so every endpoint below is documented by hand.Base URL
Operator routes are mounted under/api/v1. On the hosted platform the control plane API is https://control.tryobol.dev.
Examples use the production Control origin. Hosted API access is available after your workspace is enabled; the service is still being commissioned.
Base URL
OBOL_PUBLIC_URL.
Two routes sit outside /api/v1:
Every response under
/api/v1 carries Cache-Control: no-store. Some operator projections embed single-use federated handoff handles, and applying the header to the whole prefix means the next projection that carries something secret-adjacent gets it too. /healthz and /internal are deliberately untouched.
Authentication
Three authentications exist in Obol and none of them substitutes for another.
An operator session token is never an
ob_live_… key, and a virtual key is never accepted at /api/v1. See Virtual keys for the agent-facing credential.
Operator sessions
Send the session token as a bearer token:Operator request
{CLERK_ISSUER}/.well-known/jwks.json, caches the keys for CLERK_JWKS_TTL_S seconds (default 600), and validates:
RS256signature against the key named by the token’skid.issequal to the configuredCLERK_ISSUER.exp,iss, andsubpresent, with 30 seconds of clock leeway.azpinCLERK_AUTHORIZED_PARTIESwhen that setting is non-empty. Pinning the authorized party stops a token minted for another app on the same identity-provider instance from being replayed at control.
sub is mapped onto control’s own User row, created on first sign-in. Operator.user_id is control’s id (usr_…), never the identity provider’s subject. A subject, email, or workspace claimed by the browser is input, never authority.
The
401 message is single-valued on purpose: a caller learns the session was not accepted, never which check rejected it.
Workspace scoping
workspace_id is the tenant boundary. It is on every Postgres row and every Redis key, and it is in the path of every scoped operator route: /api/v1/workspaces/{workspace_id}/….
Authorization runs in two steps on every scoped route.
1
Membership
Control joins its own
Membership rows to the workspace. A workspace the operator is not a member of answers 404 {"detail": "workspace not found"} — the same answer as a workspace that does not exist. A distinguishable “exists but forbidden” would turn the endpoint into an oracle for workspace ids across tenants.2
Action
The membership’s role must grant the action the route performs. A role that does not answers
403 {"detail": "action not permitted"}.Roles and actions
Roles are control-owned, stored on the membership row, and independent of Cedar. Cedar authorizes agents at the gateway; these roles authorize humans at control.Frozen workspaces
A frozen workspace still serves reads. Any action outsideworkspace.read, audit.read, and usage.read answers 409 {"detail": "workspace is frozen"}, including routes that would not have written anything.
Finding your workspaces
GET /api/v1/me returns the signed-in operator, every workspace they are a member of with their role in each, and a default_workspace_id. It provisions a workspace on first sign-in so the dashboard has somewhere to land, and it is idempotent. It is the only endpoint that writes as a side effect of a read. See Operator for its full response, and Tenancy and identity for the model behind it.
Pagination
Listing endpoints use keyset pagination with opaque cursors, ordered newest first. Pass thenext_cursor from a response back as cursor to fetch the next page. A null or absent next_cursor means the listing is exhausted.
integer
Page size. Bounds are per-endpoint; see the endpoint’s own documentation.
string
Opaque keyset cursor from a previous response. Never construct one by hand — the encoding is not part of the contract, and a malformed cursor is rejected rather than interpreted.
[A-Za-z0-9_-]+. The shared implementation in app/services/pagination.py caps them at 2048 characters, validates a version field, rejects duplicate keys in the decoded object, and rejects any cursor whose key set does not match the expected shape exactly. Approval listings use a narrower cursor of their own, bounded to 1024 characters by the query parameter.
Two cursor kinds exist:
- Time cursors carry a UTC timestamp and a row id, and page a
(created_at DESC, id DESC)keyset. Used by audit, receipts, and approvals. - Group cursors carry a grouping key and the last value emitted. Used by usage summaries.
422, never a silent reset to page one.
Time windows
Endpoints that take a window acceptfrom and to as RFC 3339 timestamps with an explicit offset or Z. The window is strict and half-open: from is inclusive, to is exclusive, and from must be strictly before to. Both must be supplied together. Timestamps in responses are UTC RFC 3339 with a Z suffix.
Errors
Most control-plane failures are FastAPIHTTPExceptions and serialize as a detail field:
Error detail
detail as a list of field errors:
Validation error
Error envelope
Routes that catch these service errors themselves — the key and policy routes do — re-raise them as
HTTPException, so those responses arrive in the detail shape. The envelope handlers cover what reaches the application uncaught.Status codes
Optimistic concurrency
Mutations that revise shared state require the caller to name the version they are replacing, so a concurrent edit is a conflict rather than a silent overwrite.
A mismatch is a
409 with no writes.
Audit
Every operator mutation writes anaudit_log row in the same transaction as the act, naming the operator, the workspace, the action, and the target. Secret material never reaches the audit detail: key audit rows carry last4, never plaintext.