TypeScript SDK
Install @b3os/sdk, call every B3OS product from Node, Bun or Workers, wait on jobs, stream events, handle typed errors and read timing.
@b3os/sdk is one client with a namespace per product, generated from the OpenAPI document on top of a hand-written runtime. It ships ESM and CommonJS with type declarations and no runtime dependencies. It needs Node 20.16 or later, Bun, or Cloudflare Workers.
bashnpm install @b3os/sdk
Create a client
tsimport { B3OS } from "@b3os/sdk";const b3os = new B3OS(); // reads B3OS_API_KEY
| Option | Environment | Meaning |
|---|---|---|
apiKey | B3OS_API_KEY | A b3os_sk_ API key or a b3os_pat_ CLI token |
baseUrl | B3OS_BASE_URL | The API host, https://beta-api.b3os.org by default |
nodeUrl | B3OS_API_URL | The node's own URL, set for you inside a sandbox; node operations go there without the key |
timeoutMs, maxRetries, maxRetryDelayMs, headers, fetch, onResponse | Per-client defaults; onResponse sees every response head |
Inside a B3OS sandbox, B3OS_API_URL is http://127.0.0.1:7700, so the client calls the node with no key and runs as the sandbox's grant. Account operations (keys, usage, limits, organizations, projects and the pack registry) are served only at the API host, so the client sends those there, with the key if one is set.
The client also has a Unix-socket transport (socketPath, B3OS_SOCKET) that B3OS's own tools use on the node itself. Your code never runs there and never needs it.
Call a product
tsconst page = await b3os.vision.scrape({ url: "https://example.com", formats: ["markdown"] });console.log(page.markdown);const sandbox = await b3os.sandboxes.createSandbox({ template: "base" });await b3os.sandboxes.writeFile(sandbox.id, { path: "/workspace/main.py", mkdir: 1 }, "print(2 + 2)\n");const result = await b3os.sandboxes.exec(sandbox.id, { cmd: "python3 /workspace/main.py" });console.log(result.stdout);await b3os.sandboxes.deleteSandbox(sandbox.id);
A method takes, in order: the path parameters, an object of query and header parameters when the operation has any, the request body, and request options. Namespaces are vision, render, sandboxes, routines, actions, vault and infer, and for your account keys, usage, limits, requests, webhooks, orgs and projects. The method index lists every method.
Request options, the last argument of every method: idempotencyKey, timeoutMs, maxRetries, signal, headers and traceparent. Every type in the API is exported, for example import type { Sandbox, ScrapeRequest } from "@b3os/sdk".
For an operation the client does not wrap, b3os.request({ method, path, query, body }) sends it with the same key, retries and errors.
Lists
Awaiting a list method returns one page (data and next_cursor); for await walks every item across pages:
tsfor await (const s of b3os.sandboxes.listSandboxes({ status: "running" })) console.log(s.id);
Jobs
Methods that start a job (crawls, batch scrapes, videos, routine runs) return a handle. Awaited on its own, it gives the job as created. .wait() and .events(), called on the handle before you await it, shape the creating request itself:
ts// Wait: ?wait on the create, then the job's event stream until it endsconst crawl = await b3os.vision.createCrawl({ url: "https://docs.example.com", limit: 50 }).wait();console.log(crawl.status, crawl.result);// Or follow its events from the creating request on; the stream resumes after a dropped connectionfor await (const event of b3os.vision.createCrawl({ url: "https://docs.example.com" }).events()) { if (event.type === "page.succeeded") console.log(event.page?.url);}// Any job by its IDconst run = await b3os.jobs.wait("run_01m5t5fr06dx6azc42f4wbzf5s");console.log(run.status);
.wait() throws B3OSJobError, with the job on .job, when the job fails or is canceled; pass { throwOnFailure: false } to get the job back instead. b3os.jobs also has get, events and cancel for any job ID.
Streams
Streams are async iterables:
tsfor await (const event of b3os.sandboxes.exec(sandbox.id, { cmd: "npm test" }, { stream: true })) { if (event.type === "exec.stdout") process.stdout.write(event.data ?? "");}
Job and log streams resume after a dropped connection with Last-Event-ID. Command output and chat completions cannot resume, so a drop ends them with the client error stream_interrupted. stream.close() ends a loop early.
Errors
A failed call throws B3OSError, carrying the problem's code, status, type (the link to its entry on the errors page), title, detail, requestId, errors (validation pointers), problem (the whole body), retryAfterMs, retryable and timing:
tsimport { B3OSError } from "@b3os/sdk";try { await b3os.sandboxes.getSandbox("sbx_01m5t68q1b590rj801nbzsqzkk");} catch (error) { if (B3OSError.is(error, "sandbox_not_found")) console.log("gone", error.requestId); else throw error;}
Branch on code, and keep requestId for support and for the request log.
Failures no server reported carry codes of their own, which are not on the errors page: connection_error (B3OSConnectionError), timeout (B3OSTimeoutError), aborted (B3OSAbortError), invalid_response, stream_interrupted and unsupported_transport.
Retries and idempotency
The client retries at most maxRetries times, with exponential backoff and jitter, or after Retry-After:
- always, when the server refused the request before doing any work:
rate_limited,concurrency_limited,node_unavailable,product_unavailable,capacity_unavailable,idempotency_request_in_progress, and refused connections; - for calls that are safe to repeat (
GET,PUT,DELETE, or a call with an idempotency key), also retryable5xxanswers and dropped connections; - never after a timeout, and never when
Retry-Afteris longer thanmaxRetryDelayMs.
Operations that accept an Idempotency-Key get one generated per call, and every retry of that call reuses it. Pass your own to make your own retries safe too:
tsawait b3os.actions.call( { ref: "io.b3os/github/create-issue@1", input: { owner: "acme", repo: "web", title: "Flaky test" } }, { idempotencyKey: crypto.randomUUID() },);
Timing
.withResponse() returns the result together with the response, including its parsed Server-Timing:
tsconst { data, response } = await b3os.vision.scrape({ url: "https://example.com" }).withResponse();console.log(data.tier, response.requestId, response.timing?.total_ms, response.timing?.phases);
responseOf(result) returns the same for any object a call returned, and response.attempts counts retries.
Terminals and CDP
tsconst pty = await b3os.sandboxes.openPty(sandbox.id, { cols: 120, rows: 40 });pty.onData((bytes) => process.stdout.write(bytes));pty.write("ls -la\n");const { code } = await pty.exit;const cdp = await b3os.vision.connectCdp("brs_01m5t6vhkw4a8fa4tb5w4ffz37"); // URL with a single-use ticketconsole.log(code, cdp.url);
These open WebSockets, so they need a global WebSocket (Node 22 or later, Bun, Workers). Pass cdp.url to Playwright's chromium.connectOverCDP or Puppeteer's connect; see browser sessions.
Webhooks
verifyWebhook from @b3os/sdk/webhooks checks a delivery's B3OS-Signature and returns the event; see verify the signature.
Smaller bundles
Each namespace is also a subpath, so a bundle can carry one product:
tsimport { B3OSCore } from "@b3os/sdk/core";import { SandboxesAPI } from "@b3os/sdk/sandboxes";const sandboxes = new SandboxesAPI(new B3OSCore({ apiKey: process.env.B3OS_API_KEY }));
The package has no side effects, and a bundle for Workers or browsers contains no node: imports.