@obol/sdk is a typed client for the routes in 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

string
required
Control plane origin, no trailing path.
string | () => string | Promise<string>
required
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.
string
required
Every workspace-scoped call is made against this workspace.
function
Injected transport. Used by the package’s own tests to run against recorded responses without opening a socket.
object
Extra headers on every request. Never a vendor credential.
AbortSignal
Applied to every request.

Methods

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()

string
required
A Worker id (wkr_…), or a slug. A slug costs one extra listing call — the shipped way to turn a name into an id.
(estimate) => boolean | Promise<boolean>
Called with the estimate before the run starts. Return false and nothing starts: run() throws EstimateRejectedError carrying the estimate. Defaults to accepting.
boolean
default:"true"
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

An amount is a tagged union, and the unknown arm has no numeric field, so these do not compile:
Narrowing is the only way through:
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

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

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.
matchFixAction is exhaustive over the closed seven-variant union. If the contract grows an eighth variant, it stops compiling.

The revision digest

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

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.