--- title: "Workers quickstart" description: "Create a Worker from a reviewed template, publish v1, and read its cost card — with curl and with the TypeScript SDK." --- This walks from a signed-in account to a published Worker and a cost estimate. Every request below was checked against the shipped router in `apps/control/app/api/workers.py`. The last step — starting the run — does not complete as of 2026-09-10. The orchestration service now has an image and Compose services, but the joins that would let it accept a run are still being built, so a start reaches the hand-off and is refused. See [Overview](/workers/overview). Everything before it works today. ## Before you start Workers routes are **operator** routes on the control plane. They authenticate a human's dashboard session; a virtual key (`ob_live_…`) authenticates an agent at the gateway and is never accepted here. See [Control plane API](/api-reference/overview) for how to obtain a session token. ```bash Environment export OBOL_CONTROL_URL=https://control.tryobol.dev export OBOL_SESSION_TOKEN=... # operator session, not a virtual key export OBOL_WORKSPACE_ID=ws_... export WS="$OBOL_CONTROL_URL/api/v1/workspaces/$OBOL_WORKSPACE_ID" ``` ```bash curl -fsS "$WS/worker-environments" \ -H "Authorization: Bearer $OBOL_SESSION_TOKEN" ``` ```json Trimmed { "schema_version": 1, "workspace_id": "ws_acme_prod", "as_of": "2026-09-09T23:00:24Z", "classes": { "code": { "availability": "available", "backend_readiness": "configured", "runnable": true, "backend": "e2b", "isolation_claim": "vendor_attested_microvm", "allocation_behavior": "allocates" }, "browser": { "availability": "available", "backend_readiness": "configured", "runnable": true, "backend": "e2b", "isolation_claim": "vendor_attested_microvm", "allocation_behavior": "allocates" }, "desktop": { "availability": "beta", "backend_readiness": "not_configured", "runnable": false, "backend": "none", "isolation_claim": "none", "allocation_behavior": "fails_closed" }, "android": { "availability": "beta", "backend_readiness": "not_configured", "runnable": false, "backend": "none", "isolation_claim": "none", "allocation_behavior": "fails_closed" }, "ios": { "availability": "waitlist", "backend_readiness": "not_configured", "runnable": false, "backend": "none", "isolation_claim": "none", "allocation_behavior": "fails_closed" } }, "capabilities": { "can_read": true, "can_author": true, "can_run": true, "can_publish": true, "can_take_control": true, "can_manage_billing": true } } ``` `runnable` is the answer, not `availability`. `capabilities` tells you what your own role permits — render against it rather than guessing. That response is one deployment's answer, not the shape you should expect. Control asserts no backend by default, so a deployment that has configured nothing reports `"backend": "none"`, `"backend_readiness": "not_configured"` and `"runnable": false` on every class, including `code` and `browser`. `isolation_claim` is derived from whichever backend resolved — `vendor_attested_microvm` for the Obol-operated E2B fleet, `development_only` for the Docker development backend and the demo backend — and a deployment never gets to state its own. Read what that claim does and does not cover, in full, on [Environments](/workers/environments). ```bash curl -fsS "$OBOL_CONTROL_URL/api/v1/worker-templates?category=browser" \ -H "Authorization: Bearer $OBOL_SESSION_TOKEN" ``` Each entry is `{"template": …, "availability": …}`. A template gated by an environment class your deployment cannot run is **returned with its gate**, not hidden — render the gate instead of a start button. A coding template that must run tests, builds or installs is startable when this deployment's `code` class is runnable; it is no longer blocked merely because it needs sandbox commands. Control's `availability.runnable` is still the only field a start button may read. A Worker runs as an existing Agent Passport, so `agent_passport_id` is required. ```bash curl -fsS -X POST "$WS/workers" \ -H "Authorization: Bearer $OBOL_SESSION_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "slug": "checkout-qa", "display_name": "Checkout QA", "agent_passport_id": "agt_release", "template_id": "website-qa-sweep", "environment_classes": ["browser"], "instructions": "Walk the storefront and report what broke." }' ``` ```json 201 { "resource_id": null, "before": null, "after": null } ``` That response really is all nulls today: the router projects an operation outcome that the definitions service does not return. Recover the Worker by listing and matching on your slug — the next step does exactly that. ```bash WORKER_ID=$(curl -fsS "$WS/workers" \ -H "Authorization: Bearer $OBOL_SESSION_TOKEN" \ | python3 -c 'import json,sys; print(next(w["worker_id"] for w in json.load(sys.stdin)["workers"] if w["slug"]=="checkout-qa"))') curl -fsS "$WS/workers/$WORKER_ID/versions" \ -H "Authorization: Bearer $OBOL_SESSION_TOKEN" ``` The draft arrives pre-filled with the safe defaults: a 10-minute run timeout, a 15-minute approval window, two infrastructure retries and zero ambiguous-write retries, 7-day artifact retention, concurrency of one, an allowlist network policy, an ephemeral browser session, and production writes requiring approval. Publishing pins three things, because three different races are being refused: which draft (`revision_id`), its exact content (`revision_digest`), and the published pointer somebody else may have advanced (`expected_published_revision_id`, `null` for a first publish). `revision_digest` is the **governed digest** control computed for the draft you read. The versions route returns it as `draft_governed_digest`, and every revision in the list carries its own `governed_digest`. Send it back unchanged. Never reconstruct it: an edit made after you read the draft changes the digest, so the publish is refused rather than shipping content nobody reviewed (ADR-0130). ```bash read -r REVISION_ID DIGEST <<<"$(curl -fsS "$WS/workers/$WORKER_ID/versions" \ -H "Authorization: Bearer $OBOL_SESSION_TOKEN" | python3 -c ' import json, sys doc = json.load(sys.stdin) rev = next(r for r in doc["revisions"] if r["state"] == "draft") print(rev["revision_id"], doc["draft_governed_digest"]) ')" curl -fsS -X POST "$WS/workers/$WORKER_ID/versions" \ -H "Authorization: Bearer $OBOL_SESSION_TOKEN" \ -H "Content-Type: application/json" \ -d "{\"revision_id\": \"$REVISION_ID\", \"revision_digest\": \"$DIGEST\", \"expected_published_revision_id\": null}" ``` **Test before you publish.** A Worker still in draft can start a test run of its draft revision: `test_mode: true` on the run start. The test run's key carries test mode, and the gateway refuses every tool call that is not read-only, so a test never makes a vendor-side write (ADR-0128). The outcome is recorded as the revision's `test_status`. Publishing an untested revision succeeds, but returns the warning `revision_untested`. ```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" } } ``` The SDK reads the digest for you: see below. ```bash curl -fsS -X POST "$WS/run-estimates" \ -H "Authorization: Bearer $OBOL_SESSION_TOKEN" \ -H "Content-Type: application/json" \ -d "{\"worker_id\": \"$WORKER_ID\"}" ``` ```json Trimmed { "estimate": { "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" }, "acknowledgement": { "max_total_micros_usd": 5000000, "deadline_seconds": 600 } } ``` An unknown amount has no number in it at all — that is the contract refusing to let a missing estimate render as `$0.00`. The hard stop is always exact, because a run cannot be cancelled at an unknown ceiling. See [Control costs](/workers/control-costs). `acknowledged_hard_stop` must be exactly the `acknowledgement` you were just handed. ```bash curl -sS -X POST "$WS/worker-runs" \ -H "Authorization: Bearer $OBOL_SESSION_TOKEN" \ -H "Content-Type: application/json" \ -d "{ \"worker_id\": \"$WORKER_ID\", \"inputs\": {\"start_url\": \"https://staging.example.test\"}, \"acknowledged_hard_stop\": {\"max_total_micros_usd\": 5000000, \"deadline_seconds\": 600} }" ``` If the estimate moved in between, control answers `409` with the current card rather than starting a run against a ceiling you did not see: ```json 409 { "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 } } } ``` The run starts against the pinned revision. Follow it with `GET $WS/worker-runs/{run_id}`, which returns its state, its `evidence_summary` (what the Worker reported, kept apart from what its receipts show) and, if it fails, a typed failure with a `fault_domain` and a fix action. See [Debug a failed run](/workers/debug-a-failed-run). ## The same thing with the SDK ```ts import { createClient, formatAmount } from "@obol/sdk"; const obol = createClient({ baseUrl: process.env.OBOL_CONTROL_URL!, sessionToken: process.env.OBOL_SESSION_TOKEN!, workspaceId: process.env.OBOL_WORKSPACE_ID!, }); await obol.workers.create({ slug: "checkout-qa", display_name: "Checkout QA", agent_passport_id: "agt_release", template_id: "website-qa-sweep", environment_classes: ["browser"], instructions: "Walk the storefront and report what broke.", }); const worker = await obol.workers.findBySlug("checkout-qa"); await obol.workers.publishDraft(worker.id); const started = await obol.workers.run(worker.id, { inputs: { start_url: "https://staging.example.test" }, confirm: (estimate) => { console.log( "ceiling", formatAmount(estimate.max_total, { unknownLabel: "not known" }), "hard stop", estimate.hard_stop.max_total_micros_usd, ); return estimate.hard_stop.max_total_micros_usd <= 5_000_000; }, }); ``` `run()` fetches the estimate, calls `confirm`, and starts the run with the acknowledgement control just produced. On `409 estimate_stale` it calls `confirm` again with the **new** card before retrying — an approval of the old ceiling is never reused for a new one. See the [SDK reference](/api-reference/workers-sdk). ## Try it without connecting anything A demo run uses Obol-owned demo targets: no OAuth, no card, never billed, and labelled a demo everywhere it appears. `demo` is set by control; a request body has no field for it. ```bash curl -sS -X POST "$WS/worker-demo-runs" \ -H "Authorization: Bearer $OBOL_SESSION_TOKEN" \ -H "Content-Type: application/json" \ -d '{"template_id": "website-qa-sweep"}' ``` You do not have to do steps 3 to 5 first. A demo still runs a pinned revision — a demo whose behaviour is not pinned is one nobody can reproduce — so if your workspace has no published Worker for that template, the route seeds one from the reviewed template before it starts anything, idempotently per template. The entitlement gate runs *before* the seed, so a workspace that may not run the template's environment class is told so without acquiring a Worker for it. `409 demo_revision_missing` remains as the backstop for a caller that reached the run path another way. The seed also creates one reserved passport per workspace, `obol-workers-demo`, because a Worker runs as an Agent Passport and a fresh workspace has none. It denies everything — no tools, no models, no scopes, destructive actions denied, in every environment — because a demo reaches Obol-owned fixture targets and no system of yours.