---
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.