--- title: "Workers" description: "Every shipped Obol Workers route: templates, environments, entitlements, drafting, Workers, revisions, workflows, estimates, runs, artifacts, session profiles and the Doctor." --- 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](/api-reference/overview) 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. | Action | Routes | Least role | |---|---|---| | `agentops.read` | every read, the estimate, the Doctor | viewer | | `change.propose` | drafting, authoring, starting or steering a run | developer | | `change.apply` | publishing a revision, taking a control lease | admin | `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 | Method | Path | |---|---| | `GET` | `/worker-templates` | | `GET` | `/workspaces/{ws}/worker-environments` | | `GET` | `/workspaces/{ws}/workers-entitlements` | | `GET` | `/workspaces/{ws}/permission-presets` | | `GET` | `/workspaces/{ws}/execution-modes` | | `POST` | `/workspaces/{ws}/worker-drafts` | | `GET` `POST` | `/workspaces/{ws}/workers` | | `GET` `PATCH` | `/workspaces/{ws}/workers/{worker_id}` | | `GET` `POST` | `/workspaces/{ws}/workers/{worker_id}/versions` | | `GET` `POST` | `/workspaces/{ws}/workflows` | | `GET` `POST` | `/workspaces/{ws}/workflows/{workflow_id}/versions` | | `POST` | `/workspaces/{ws}/run-estimates` | | `GET` `POST` | `/workspaces/{ws}/worker-runs` | | `GET` | `/workspaces/{ws}/worker-runs/{run_id}` | | `POST` | `/workspaces/{ws}/worker-runs/{run_id}/cancel` | | `POST` | `/workspaces/{ws}/worker-runs/{run_id}/pause` | | `POST` | `/workspaces/{ws}/worker-runs/{run_id}/resume` | | `POST` | `/workspaces/{ws}/worker-runs/{run_id}/control-lease` | | `GET` | `/workspaces/{ws}/worker-runs/{run_id}/artifacts/{artifact_id}` | | `POST` | `/workspaces/{ws}/worker-demo-runs` | | `GET` `POST` | `/workspaces/{ws}/worker-session-profiles` | | `GET` | `/workspaces/{ws}/worker-session-profiles/{session_profile_id}` | | `POST` | `/workspaces/{ws}/worker-session-profiles/{session_profile_id}/revoke` | | `GET` | `/workspaces/{ws}/workers-doctor` | 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. | Status | Means | |---|---| | 401 | no operator session | | 403 | the workspace role does not grant the action | | 404 | no such workspace, not a member, or no such object | | 402 / 409 | a typed pre-allocation refusal, or a state conflict | | 422 | the body or a path parameter did not validate | | 503 | a Workers service is not available in this deployment | 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. `development`, `browser`, `research`, `mobile`, `desktop`. ```bash curl -fsS "$OBOL_CONTROL_URL/api/v1/worker-templates?category=browser" \ -H "Authorization: Bearer $OBOL_SESSION_TOKEN" ``` ```json 200 (trimmed) { "schema_version": 1, "templates": [ { "template": { "template_id": "website-qa-sweep", "name": "Check a website for broken pages", "environment_class": "browser", "demo_supported": true, "...": "…" }, "availability": { "status": "available", "reason": "Browser environments are available, so this template can run today.", "gated_by_environment_classes": [] } } ] } ``` 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](/workers/environments) for what each field means and why `runnable` is the only answer to "can this start". ### `GET /api/v1/workspaces/{ws}/workers-entitlements` The `workers_entitlement` contract: plan, included runs, permitted classes, concurrency, retention, trigger flags, trial allowance, `card_on_file`. The whole ladder as structure. **No money field appears on it**, and each entry carries `amounts_are_configuration: true`. `free`, `builder`, `pro`, `team` or `enterprise`. `runs_started`, `demo_runs_started`, `active_runs`, `active_environments`, `period_start`. 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. ```json 200 (one preset of three) { "schema_version": 1, "workspace_id": null, "preset": "safe_writes", "display_name": "Safe writes", "summary": "Can create things you can undo \u2014 branches, pull requests, issues \u2014 and cannot merge, delete, or move money.", "default_for_new_workers": true, "allows": [ { "statement": "Nothing yet. Connect a system and its reviewed capabilities appear here.", "capability_hint": null } ], "forbids": [ { "statement": "Click through an application's own interface to do any of the above", "capability_hint": null } ], "compiled_capabilities": { "allowed": [], "forbidden": [ { "capability_class": "computer_use_action", "environment_class": "browser", "action": "click", "description": "Click a merge, delete, payment or submit control in an application's own interface", "semantic_guarantee": "none", "high_impact": true, "allowed_domains": [] } ] }, "unlisted_capability_default": "denied", "cedar_policy_ids": [ "workers-baseline", "workers-safe-writes" ] } ``` ### `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. ```json 200 (the autonomous mode) { "schema_version": 1, "mode": "autonomous", "display_name": "Autonomous", "summary": "Asks less often. Every budget, deadline and policy still applies, and a high-impact click still needs a person.", "is_default": false, "approval_requirements": [ { "action_class": "read", "approval": "not_required", "note": "Reading changes nothing." }, { "action_class": "verified_reversible_write", "approval": "not_required", "note": "A typed write you can undo executes inside its limits." }, { "action_class": "verified_irreversible_write", "approval": "required", "note": "A merge, a delete or a payment always asks, in every mode." }, { "action_class": "production_write", "approval": "required_in_production", "note": "Production writes still ask, even here." }, { "action_class": "computer_use_action", "approval": "forbidden_unattended", "note": "A person holds the control lease and drives the interface." }, { "action_class": "spend_above_threshold", "approval": "required", "note": "A step over the spend threshold still asks." }, { "action_class": "child_run_spawn", "approval": "not_required", "note": "A child run executes inside the parent run's budget and authority." } ], "high_impact_ui_action_policy": "requires_human_control", "limits_enforced": "always", "approval_timeout_seconds": 900 } ``` ## 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: ```json 409 { "detail": { "code": "denied", "message": "Assistant access has not been enabled for this workspace.", "href": "/dashboard/settings/assistant" } } ``` 1–4000 characters. Screened for credential material: a recognisable secret is refused with a 422 that names the field and echoes nothing back. Seed the draft from a reviewed template. A preference, not an authority. `safe`, `balanced`, `autonomous`. `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` `draft`, `published` or `retired`. ```json 200 { "workers": [ { "schema_version": 1, "worker_id": "wkr_60bca556146c4b7e8539be939eab660d", "workspace_id": "ws_acme_prod", "env": "prod", "slug": "checkout-qa", "display_name": "Checkout QA", "description": null, "agent_passport_id": "agt_release", "lifecycle": "draft", "draft_revision_id": "wrv_f65fd1bd562d42ba8b68b89f533e5147", "published_revision_id": null, "latest_revision_number": 1, "template_id": "website-qa-sweep", "created_by": { "kind": "user", "id": "usr_test", "display_name": null }, "created_at": "2026-09-09T23:28:28Z", "updated_at": "2026-09-09T23:28:28Z" } ], "capabilities": { "can_read": true, "can_author": true, "can_run": true, "can_publish": true, "can_take_control": true, "can_manage_billing": true } } ``` 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` `^[a-z0-9][a-z0-9-]*$`, up to 64 characters. 1–200 characters. An existing Agent Passport in the workspace's anchor. A Worker runs *as* an identity; there is no unattributed actor. Up to 2000 characters. Recorded on the Worker; also what a demo run looks a Worker up by. 1–8 class names; defaults to a single `code` environment. Up to 20000 characters. Screened for credential material. ```json 201 { "resource_id": null, "before": null, "after": null } ``` 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` Which draft is being published. `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](/workers/quickstart) has a shell version. The revision the operator believed was live. `null` means "this Worker had never been published" — a first publish and a supersede are different acts. ```json 201 { "resource_id": "wkr_2665449a0f8444f99719ab9f2639a787", "before": { "lifecycle": "draft", "published_revision_id": null }, "after": { "lifecycle": "published", "published_revision_id": "wrv_dd0f0f0e7d1a4a6cb0e9e4d2f0b7c611", "revision_number": 1, "revision_digest": "sha256:9b5dcfbd28e56a766afbc8d98082f93734d26bc75a952b5f92a84192f20a8ebb", "permission_policy_revision_id": "workers-safe-writes" } } ``` 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](/workers/schedule-a-workflow). ## Estimates ### `POST /api/v1/workspaces/{ws}/run-estimates` Reads only; allocates nothing, starts nothing. Requires `agentops.read`. Price a template before a Worker exists. ```json 200 (trimmed) { "estimate": { "schema_version": 1, "workspace_id": "ws_acme_prod", "worker_revision_id": "wrv_dd0f0f0e7d1a4a6cb0e9e4d2f0b7c611", "environment": [ { "class": "browser", "label": "Browser — isolated Chromium", "estimated_active_seconds": { "known": false, "reason": "no_historical_basis", "note": "This Worker has not run in a browser environment before." }, "runnable": true } ], "estimated_runtime": { "known": false, "reason": "no_historical_basis", "note": "This Worker has not run before, so its runtime is not yet known." }, "max_model_budget": { "known": false, "reason": "model_price_unknown", "note": "This Worker has no model ceiling set, so the maximum is not stated." }, "max_environment_cost": { "known": false, "reason": "environment_rate_unknown", "note": "Environment rates are not configured in this deployment." }, "max_total": { "known": false, "reason": "component_unknown", "note": "Model cost is unknown, so the total cannot be stated. The hard stop below still applies." }, "hard_stop": { "max_total_micros_usd": 5000000, "deadline_seconds": 600, "on_reach": "cancel_run", "max_environment_seconds": 600 }, "demo": false, "amounts_are_illustrative": true, "basis": "worker_limits", "estimated_at": "2026-09-09T23:02:23Z" }, "acknowledgement": { "max_total_micros_usd": 5000000, "deadline_seconds": 600 }, "capabilities": { "...": "…" } } ``` 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` Defaults to the published revision. Screened for credential material. May narrow the published mode — asking for *more* approvals — and never widen it. Reduced budgets, irreversible actions blocked; for browser and desktop, shadow mode. 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`: ```json { "detail": { "code": "estimate_stale", "what": "This run's cost estimate changed before it started.", "why": "The hard stop you confirmed is not the one that applies now.", "suggested_fix": "Review the updated estimate and start the run again.", "estimate": { "...": "the current card" }, "acknowledgement": { "max_total_micros_usd": 5000000, "deadline_seconds": 600 } } } ``` 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": , "estimate": …, "capabilities": …}`. ### `POST /api/v1/workspaces/{ws}/worker-demo-runs` `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` Repeatable: `?state=failed&state=canceled`. Not comma-joined. 1–200. Keyset cursor from `next_cursor`. ```json 200 { "items": [], "next_cursor": null, "filters_applied": [], "capabilities": { "...": "…" } } ``` 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`. 30–3600. `assisted_login`, `takeover`, `inspection`. ```json 200 { "run_id": "wrun_…", "control_lease_id": "wcl_…", "holder_user_id": "usr_dana", "expires_at": "2026-09-09T23:07:23Z" } ``` ### `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](/workers/authenticated-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. The run whose environment was signed in to. 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. `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. 1–120 characters. 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. 1–64 entries. There is no wildcard: widening a domain allowlist is never the escape from a UI-action boundary. 1–8. 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` 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` `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` Scope the report to one Worker. 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.