The Workers surface is mounted under /api/v1 on the control plane and authenticates an operator session token. A virtual key is never accepted here. See Control plane API for base URL, sessions and the shared error envelope.
This reference is written from apps/control/app/api/workers.py and the frozen contracts in packages/proto/workers/. Every request and response below was executed against that router. There is no committed OpenAPI document for the control plane.

Gates

Three, in order, on every workspace route without exception:
  1. Membership. 404 for both “no such workspace” and “not yours”, so the path cannot be used to enumerate tenants.
  2. Action. Default-deny against the workspace role.
  3. The service, which re-checks whatever matters to it.
agentops.read is in the frozen-workspace exemption set, so a frozen workspace still reads and still cannot run. Every workspace read carries a capabilities block computed from the caller’s own role — can_read, can_author, can_run, can_publish, can_take_control, can_manage_billing. Render against it; a projection never shows an affordance the caller cannot use.

Route table

Orchestration callbacks (/internal/workers/…) are mounted on the private listener only and are not an operator surface.
Starting a run does not complete, as of 2026-09-10. POST /worker-runs and POST /worker-demo-runs reach the hand-off to the orchestration service and are refused there: where the workers Compose profile is up, the hand-off answers 503 because it cannot yet assemble a run input; where it is not reachable at all, the transport error is not translated by the router and the response is a 500 rather than a typed 503. A run that never started has not failed, and no failure object is written for it.Everything downstream of a run — the run view, artifacts, control leases, session-profile capture — has a route that answers and nothing to answer about.

Errors

The envelope is FastAPI’s: {"detail": …}, where detail is either a short code string or an object. Typed refusals carry code, reason, what, why, suggested_fix, an optional fix_action, rule_reference and set_by: "control".
A service that supplies a detail object has its code dropped by the router, which prefers the object. revision_digest_mismatch therefore arrives as {"detail": {"revision_id": …, "expected": …}} with no code field. Do not key on code being present.

Templates

GET /api/v1/worker-templates

Not workspace-scoped: templates are reviewed data that ship with the product. Availability is resolved against the deployment’s runnable classes.
string
development, browser, research, mobile, desktop.
200 (trimmed)
A gated template is returned with its gate rather than filtered out.

Environments and entitlements

GET /api/v1/workspaces/{ws}/worker-environments

Returns the environment_availability contract plus capabilities. See Environments for what each field means and why runnable is the only answer to “can this start”.

GET /api/v1/workspaces/{ws}/workers-entitlements

object
required
The workers_entitlement contract: plan, included runs, permitted classes, concurrency, retention, trigger flags, trial allowance, card_on_file.
array
required
The whole ladder as structure. No money field appears on it, and each entry carries amounts_are_configuration: true.
string
required
free, builder, pro, team or enterprise.
object
required
runs_started, demo_runs_started, active_runs, active_environments, period_start.
object
required
Per-class availability, the same map as worker-environments.
A limit is {"kind": "limited", "value": n} or {"kind": "unlimited"} — the unlimited arm has no value.

Presets and modes

GET /api/v1/workspaces/{ws}/permission-presets

The three presets — Read only, Safe writes (the default) and Custom — compiled against this workspace’s reviewed connector packs rather than described a second time. The allows and forbids sentences come from each tool’s own reviewed description, so a change to a Cedar policy or to a connector’s EffectSpec changes this document. Hand-written dashboard copy would be a second source of truth for what a Worker may do. Each compiled capability carries capability_class, and the two classes are not interchangeable: a verified_capability is a typed governed call whose arguments Cedar reads, and a computer_use_action is a click whose application-level meaning cannot be guaranteed. The distinction is on the wire so it cannot be lost in the rendering. unlisted_capability_default is denied on every preset. Default-deny stands underneath all three.
200 (one preset of three)

GET /api/v1/workspaces/{ws}/execution-modes

Safe, Balanced and Autonomous, with exactly one marked is_default. Two fields are identical on every mode and must never be inferred from the mode’s name:
  • high_impact_ui_action_policy is requires_human_control on all three, autonomous included. A high-impact arbitrary UI action happens only while a person holds a control lease. That is a semantic boundary, not a preference.
  • limits_enforced is always. A mode named autonomous still cannot relax a budget, a deadline, a network policy or Cedar admission.
200 (the autonomous mode)

Drafting

POST /api/v1/workspaces/{ws}/worker-drafts

Turns a job description into a reviewable proposal. Requires change.propose and the workspace assistant enabled; without it:
409
string
required
1–4000 characters. Screened for credential material: a recognisable secret is refused with a 422 that names the field and echoes nothing back.
string
Seed the draft from a reviewed template.
string
A preference, not an authority.
string
safe, balanced, autonomous.
string
read_only, safe_writes, custom.
A draft is a proposal (authority: "proposal_only") and grants nothing. Which environment classes the workspace may use is assembled by control and cannot be stated in the body.

Workers

GET /api/v1/workspaces/{ws}/workers

string
draft, published or retired.
200
The router projects a worker_definition document rather than the database row, so the field names are the contract’s: worker_id rather than id, a created_by object rather than two columns, and RFC 3339 with Z. GET /workers/{id} returns the same document under a worker key.

POST /api/v1/workspaces/{ws}/workers

string
required
^[a-z0-9][a-z0-9-]*$, up to 64 characters.
string
required
1–200 characters.
string
required
An existing Agent Passport in the workspace’s anchor. A Worker runs as an identity; there is no unattributed actor.
string
Up to 2000 characters.
string
Recorded on the Worker; also what a demo run looks a Worker up by.
array
default:"code"
1–8 class names; defaults to a single code environment.
string
default:"safe_writes"
string
default:"safe"
string
Up to 20000 characters. Screened for credential material.
201
The 201 really is all nulls: the router projects an operation outcome the definitions service does not return. The Worker is created. Recover it with GET /workers and match on your slug.
The draft is pre-filled from the workspace safe defaults: a 600-second run timeout, a 900-second approval window, two infrastructure retries, zero ambiguous-effect retries, 7-day artifact retention, concurrency of one, an allowlist network policy, an ephemeral session, and production writes requiring approval.

PATCH /api/v1/workspaces/{ws}/workers/{worker_id}

Edits land on the open draft; a published revision is never edited in place. Accepts body and execution_mode. Returns the same all-null outcome as create.

GET /api/v1/workspaces/{ws}/workers/{worker_id}/versions

Returns {"revisions": [...], "capabilities": {...}}, newest revision number first. A draft carries "state": "draft" and "revision_digest": null. Each entry is a worker_revision document, not the row. Two projections matter when computing a digest: the stored body column is flattened into top-level instructions, runtime_adapter, model_constraints, environment_requests, connections, session_profile_ids and output_contract — present only when the draft has them — and permission_preset is an object, {preset, policy_revision_id}, carrying both the preset and the Cedar revision it compiled to. The digest is taken over the stored shape, so both are reassembled before hashing.

POST /api/v1/workspaces/{ws}/workers/{worker_id}/versions

string
required
Which draft is being published.
string
required
sha256: plus SHA-256 over the canonical JSON of the revision’s governed body — {allowed_capabilities, body, execution_mode, limits, permission_preset, ui_actions}, keys sorted at every depth, no whitespace between tokens, permission_preset as the bare preset string and body reassembled from the flattened fields above. It pins the content that was reviewed, which is why no route hands it out; compute it from the revision you read. @obol/sdk exposes revisionDigest(), and the quickstart has a shell version.
string
The revision the operator believed was live. null means “this Worker had never been published” — a first publish and a supersede are different acts.
201
Three 409s can come back: published_revision_moved, worker_revision_already_published, and a digest mismatch whose detail object carries revision_id and expected.

Workflows

All four routes are implemented. POST /workflows creates a Workflow and its Draft v1, stepless — the graph is authored as a second act, and publishing refuses a revision with no steps. schedules_allowed and webhooks_allowed travel from the workspace entitlement onto the draft as permitted_trigger_types, so a plan gate lands on the definition rather than on the dashboard rendering it. POST /workflows/{workflow_id}/versions takes the same three pins as a Worker publish, and validates the graph and compiles every condition expression at publish rather than at run time. There is no trigger route, so a published Workflow starts nothing on its own. See Schedule a workflow.

Estimates

POST /api/v1/workspaces/{ws}/run-estimates

Reads only; allocates nothing, starts nothing. Requires agentops.read.
string
string
string
Price a template before a Worker exists.
array
boolean
default:"false"
200 (trimmed)
An amount is {known: true, usd_micros} or {known: false, reason, note}. The unknown arm has no numeric field at all, so a missing estimate cannot render as $0.00.

Runs

POST /api/v1/workspaces/{ws}/worker-runs

string
required
string
Defaults to the published revision.
object
Screened for credential material.
string
May narrow the published mode — asking for more approvals — and never widen it.
boolean
default:"false"
Reduced budgets, irreversible actions blocked; for browser and desktop, shadow mode.
object
required
Exactly {max_total_micros_usd, deadline_seconds} from a fresh estimate’s acknowledgement.
There is no demo field. A body that omits acknowledged_hard_stop is a 422. On a stale acknowledgement, 409:
Then the entitlement gate runs, before anything chargeable is allocated: environment_class_unavailable (409), concurrency_limit_reached (409) or entitlement_exceeded (402). On success the response is {"run": <run view>, "estimate": …, "capabilities": …}.

POST /api/v1/workspaces/{ws}/worker-demo-runs

string
required
object
demo is set by control and has no field in the body. A demo runs a pinned revision, because a demo whose behaviour is not pinned is one nobody can reproduce — so where the workspace has no published Worker for the template, the route seeds one from the reviewed template through the ordinary create, edit and publish services, in the same transaction as the run and idempotently per template. The entitlement gate runs before the seed, so a workspace that may not allocate the template’s class is refused without acquiring a Worker on the way. 409 demo_revision_missing remains as the backstop for a caller that reached the run path without it, and 409 template_not_demo_supported for a template with no Obol-owned demo target. Seeding also names one reserved passport per workspace, obol-workers-demo, held on the workspace anchor and denying everything — no tools, no models, no scopes, destructive actions denied, in every environment. A workspace with no anchor is refused rather than borrowing an identity that has authority over real systems. The response is {"run": …, "estimate": …, "demo": true}.

GET /api/v1/workspaces/{ws}/worker-runs

string
Repeatable: ?state=failed&state=canceled. Not comma-joined.
string
boolean
boolean
integer
default:"50"
1–200.
string
Keyset cursor from next_cursor.
200
Rows carry measured_total_micros_usd, which is null when nothing was measured — render that as unknown, not zero — and liveness beside state.

GET /api/v1/workspaces/{ws}/worker-runs/{run_id}

The whole run page: run, liveness, environment, failure, output_contract, steps, most_expensive_step, control_lease, capabilities. output_contract is the contract evaluation and is present on a successful run too. null means the evaluation was not reported, never that it passed — completion is decided by schema validation and required artifacts, never by model prose.
Consult liveness before rendering run.state. unknown is not “running”.
A run id outside the expected shape is 422 {"detail": "invalid_run_id"}.

POST …/{run_id}/cancel, /pause, /resume

Each records intent and returns {"run_id", "state", "requested"}. requested is false when the run is already terminal. Resume re-allocates, so concurrency is checked again and can refuse with concurrency_limit_reached; it also causes policy to be re-read before the next attempt. Pausing says nothing about the environment. Whether compute is still held is environment.holds_compute on the run view, and that is what decides whether time keeps accruing.

POST …/{run_id}/control-lease

Requires change.apply.
string
required
integer
default:"300"
30–3600.
string
default:"takeover"
assisted_login, takeover, inspection.
200

GET …/{run_id}/artifacts/{artifact_id}

Metadata and an access decision — never bytes and never a storage location. download_allowed is copied from the stored column, not recomputed into a friendlier answer; credential-bearing content is never downloadable. provenance records who observed the artifact and what the observation is worth. demo stays on the artifact in every projection.

Session profiles

Mounted on the same prefix and gated the same way. Their capabilities block is this surface’s own — can_read, can_capture, can_revoke — rather than the Worker one. See Authenticated browser sessions for the custody model these routes implement, including the part Obol states rather than hides: an authenticated browser possesses usable session state inside its environment.

POST /api/v1/workspaces/{ws}/worker-session-profiles

Requires change.apply. Capture is not a login flow: it seals state from an environment a human is already holding.
string
required
The run whose environment was signed in to.
string
required
The lease you are holding on it. Re-checked against the supervision record: it must exist, be the one named, be held by this caller, and be unexpired.
string
required
browser, desktop, android or ios. code is absent from the enum, and that absence is the enforcement — a code environment cannot even name a session profile on this surface, and a body that says code is a 422 before any handler runs.
string
required
1–120 characters.
string
required
string
required
Whose account this is. There is no owner_kind: it is always a customer account, because Obol operates no pooled vendor account for customer work.
array
required
1–64 entries. There is no wildcard: widening a domain allowlist is never the escape from a UI-action boundary.
integer
integer
boolean
default:"false"
integer
default:"1"
1–8.
boolean
default:"false"
integer
default:"7"
1–30.
The body has no field a byte of session state could travel in. sealed_state_ref is derived from the workspace and the profile id and is never accepted from a caller, so control records consent, custody metadata and the door check without reading the state back or certifying the bytes.
The lease’s purpose is not persisted, so no row records that a lease was taken for assisted_login rather than takeover. Capture verifies the door is open and held by the right person; it cannot verify the sign on the door.

GET /api/v1/workspaces/{ws}/worker-session-profiles

boolean
default:"false"
Metadata only: id, environment class, the named account, expiry, revocation state, the fence when there is one, and which revisions reference it. Nothing here can be replayed. The read is not passive — a profile whose retention or idle window has closed is moved to expired before it is projected, so no surface renders an active profile that is already unusable.

GET /api/v1/workspaces/{ws}/worker-session-profiles/{session_profile_id}

One profile. This is the route an operator watches a fence land on.

POST …/worker-session-profiles/{session_profile_id}/revoke

string
default:"user_requested"
user_requested, workspace_frozen or security_review.
Requires change.apply, with one deliberate exception: a frozen workspace may still revoke. Freezing must not trap a live session inside a workspace, and workspace_frozen is itself a revocation reason. The role grant is untouched, so a viewer still cannot revoke. Read the response’s state, not the HTTP status. revoked means control observed every holder stop. revoking means the record is unallocatable, the sealed address is destroyed and the fence is applied, and at least one lease has not yet been observed to stop — a bumped fencing token is not evidence a holder halted. The fence block names those leases. Calling revoke again is safe and is how you ask whether it has landed.

Doctor

GET /api/v1/workspaces/{ws}/workers-doctor

string
Scope the report to one Worker.
string
Scope it to one Workflow.
Returns the doctor_report contract plus capabilities. A workspace-scoped report covers entitlement, availability, model connections and concurrency; a worker-scoped one adds the definition, connections, session profiles, domains, budget and triggers. The scope is on the report because a green workspace report must not be mistaken for a green Worker.
Reports are not persisted in this build: report_id names the response you are holding and cannot be fetched again.