---
title: "Workers SDK"
description: "The @obol/sdk TypeScript client: construction, every method, the money type, typed failures, and the revision digest."
---
`@obol/sdk` is a typed client for the routes in [Workers](/api-reference/workers). It lives at `packages/sdk` in the Obol repository, is source-first with no build step, and adds exactly one thing the HTTP surface does not have: `workers.run()`, which performs the estimate → confirm → start sequence the run route requires.
## Construct a client
```ts
import { createClient } from "@obol/sdk";
const obol = createClient({
baseUrl: process.env.OBOL_CONTROL_URL!, // https://control.tryobol.dev
sessionToken: process.env.OBOL_SESSION_TOKEN!, // operator session, not a virtual key
workspaceId: process.env.OBOL_WORKSPACE_ID!, // ws_…
});
```
Control plane origin, no trailing path.
An operator session token, or a function that produces one per request so a refreshed session is picked up. Never an `ob_live_…` key: virtual keys authenticate agents at the gateway and are not accepted at `/api/v1`.
Every workspace-scoped call is made against this workspace.
Injected transport. Used by the package's own tests to run against recorded responses without opening a socket.
Extra headers on every request. Never a vendor credential.
Applied to every request.
## Methods
| Method | Route |
|---|---|
| `listTemplates({category})` | `GET /worker-templates` |
| `environments()` | `GET …/worker-environments` |
| `entitlements()` | `GET …/workers-entitlements` |
| `permissionPresets()` | `GET …/permission-presets` |
| `executionModes()` | `GET …/execution-modes` |
| `doctor({worker_id, workflow_id})` | `GET …/workers-doctor` |
| `draft({job_description, …})` | `POST …/worker-drafts` |
| `list({lifecycle})` / `create(input)` | `GET` / `POST …/workers` |
| `get(id)` / `patch(id, input)` | `GET` / `PATCH …/workers/{id}` |
| `listVersions(id)` / `publish(id, pins)` | `GET` / `POST …/workers/{id}/versions` |
| `publishDraft(id)` | reads the draft, computes its digest, publishes |
| `digestFor(revision)` | the digest for one revision, without publishing |
| `findBySlug(ref)` | `GET …/workers`, matched on `slug` or `id` |
| `listWorkflows()` / `createWorkflow()` / `listWorkflowVersions()` | `GET` / `POST …/workflows`, `GET …/workflows/{id}/versions` |
| `publishWorkflow(id, pins)` | `POST …/workflows/{id}/versions` |
| `estimate(input)` | `POST …/run-estimates` |
| `run(ref, options)` | estimate → confirm → `POST …/worker-runs` |
| `startRun(input)` | `POST …/worker-runs` |
| `startDemoRun({template_id, inputs})` | `POST …/worker-demo-runs` |
| `listRuns(filters)` | `GET …/worker-runs` |
| `getRun(runId)` | `GET …/worker-runs/{run_id}` |
| `cancelRun` / `pauseRun` / `resumeRun` | the three intent routes |
| `takeControl(runId, input)` | `POST …/worker-runs/{run_id}/control-lease` |
| `getArtifact(runId, artifactId)` | `GET …/artifacts/{artifact_id}` |
There is no trigger method because there is no trigger route. There is no session-profile method either: those four routes are on the HTTP surface only, and a client calls them directly for now.
The client's own doc comments still describe the three Workflow listing and creation routes as answering 503. That was true when they were written and is not true now — `list_workflows`, `create_workflow` and `list_workflow_revisions` exist in the definitions service, and the routes recover the moment a symbol exists. Trust the route table above.
## `run()`
```ts
const started = await obol.workers.run("checkout-qa", {
inputs: { start_url: "https://staging.example.test" },
execution_mode: "safe",
test_mode: false,
confirm: (estimate) => estimate.hard_stop.max_total_micros_usd <= 5_000_000,
retryOnStaleEstimate: true,
});
```
A Worker id (`wkr_…`), or a slug. A slug costs one extra listing call — the shipped way to turn a name into an id.
Called with the estimate before the run starts. Return `false` and nothing starts: `run()` throws `EstimateRejectedError` carrying the estimate. Defaults to accepting.
On `409 estimate_stale`, call `confirm` again with the **new** card and retry once. An approval of the old ceiling is never reused for a new one. Set `false` to receive `EstimateStaleError` instead.
The response is the route's own: `{run, estimate, capabilities}`, where `run` is the whole run view.
## Money
```ts
import { formatAmount, isKnownAmount, requireKnownAmount } from "@obol/sdk";
```
An amount is a tagged union, and the unknown arm has no numeric field, so these do not compile:
```ts
estimate.max_total.usd_micros; // Property 'usd_micros' does not exist
estimate.max_total.usd_micros ?? 0; // same
```
Narrowing is the only way through:
```ts
if (estimate.max_total.known) {
estimate.max_total.usd_micros; // number
} else {
estimate.max_total.reason; // "component_unknown", …
estimate.max_total.note; // a sentence for a person
}
```
`formatAmount(amount, {unknownLabel})` renders both arms; `unknownLabel` is required rather than defaulted, because the honest phrase differs by surface. `requireKnownAmount(amount, what)` throws rather than returning a number it does not have — there is deliberately no helper that yields zero.
## Presets and modes
```ts
const { presets } = await obol.workers.permissionPresets();
presets.map((p) => p.preset); // ["read_only", "safe_writes", "custom"]
presets[1]!.allows; // sentences, from each tool's own description
presets[1]!.unlisted_capability_default; // "denied" on every preset
const { modes } = await obol.workers.executionModes();
modes.every((m) => m.high_impact_ui_action_policy === "requires_human_control"); // true
modes.every((m) => m.limits_enforced === "always"); // true
```
Render those two fields from this answer rather than from your own copy: they are identical on every mode, `autonomous` included, and a UI that implied otherwise would be describing a product Obol does not ship.
## Typed failures
| Class | Status | Carries |
|---|---|---|
| `ObolAuthError` | 401 | — |
| `ObolPermissionDeniedError` | 403 | — |
| `ObolNotFoundError` | 404 | — |
| `ObolConflictError` | 409 | `detail` |
| `ObolValidationError` | 422 | `detail` |
| `ObolServiceUnavailableError` | 503 | control's own message |
| `EstimateStaleError` | 409 | `estimate`, `acknowledgement`, `what`, `why`, `suggested_fix` |
| `AllocationRefusedError` | 402 / 409 | `reason`, `what`, `why`, `suggested_fix`, `fix_action`, `rule_reference`, `environment_class` |
Every one carries `status`, `detail`, `method`, `path` and `code`. `code` is `null` when the response carried none — which happens whenever a service supplied a detail object, so do not key on it being present.
```ts
import { AllocationRefusedError, matchFixAction } from "@obol/sdk";
try {
await obol.workers.run("checkout-qa");
} catch (error) {
if (error instanceof AllocationRefusedError) {
console.log(error.reason, error.what, error.why, error.suggested_fix);
if (error.fix_action) {
matchFixAction(error.fix_action, {
reconnect_connection: (f) => reconnect(f.connection_id),
raise_budget: (f) => raiseBudget(f.scope, f.suggested_minimum_micros_usd),
widen_network_policy: (f) => allow(f.destinations),
extend_timeout: (f) => extend(f.timeout, f.suggested_seconds),
reduce_model_cost: (f) => switchModel(f.suggested_model),
request_capacity: (f) => joinWaitlist(f.environment_class),
retry: (f) => retryIn(f.after_seconds),
});
}
}
}
```
`matchFixAction` is exhaustive over the closed seven-variant union. If the contract grows an eighth variant, it stops compiling.
## The revision digest
```ts
import { revisionDigest, canonicalJson, governedBody } from "@obol/sdk";
const { revisions } = await obol.workers.listVersions(workerId);
const draft = revisions.find((r) => r.state === "draft")!;
await obol.workers.publish(workerId, {
revision_id: draft.revision_id,
revision_digest: await revisionDigest(draft),
expected_published_revision_id: null,
});
```
`revisionDigest` reproduces control's own computation: the governed body — `{allowed_capabilities, body, execution_mode, limits, permission_preset, ui_actions}` — canonicalized with keys sorted at every depth and no whitespace, SHA-256 over its UTF-8 bytes, prefixed `sha256:`. It uses WebCrypto and is therefore async, and works unchanged in Node, Deno, Bun and a browser.
The digest is taken over the shape control **stores**, and the route projects a different one: the stored `body` column arrives flattened into top-level fields and `permission_preset` arrives as `{preset, policy_revision_id}`. `governedBody()` reassembles both — `REVISION_BODY_FIELDS` is the exported list it uses — so a caller never has to know that. Getting it wrong produces a 409 with no code on it, which is why `src/digest.test.ts` pins the output against a digest the shipped service accepted.
`publishDraft(workerId)` does the whole sequence. The safety is not lost: the digest is taken over the revision that call just read, so a draft edited in between fails control's own check rather than being published unreviewed.
## Tests
```bash
cd packages/sdk
pnpm install
pnpm test # no network, no vendor
pnpm typecheck # includes compile-time proofs
```
Every fixture is a verbatim response recorded from the shipped router by `packages/sdk/scripts/record-fixtures.py`, which mounts the control app on an in-memory database and stubs exactly one boundary: the HTTP hop to the orchestration service, which as of 2026-09-10 cannot accept a run and so has no response of its own to record. `src/type-assertions.ts` holds `@ts-expect-error` assertions that certain things must **not** compile — reading an unknown amount as a number, treating `fix_action` as a string — so `pnpm typecheck` fails if any of them ever becomes legal.