Node SDK
The TypeScript SDK for Node 18 and later: install it, create a client, and every method it has.
The Node SDK (@boxline/sdk) has a typed method for every endpoint of the HTTP API. It retries safely, sends idempotency keys for you, pages through lists, and throws typed errors. It is written in TypeScript and runs on Node 18 and later.
Install
npm install @boxline/sdk playwright-coreplaywright-core is only needed to drive the browser with Playwright. The playwright package works too.
Open source (MIT licence): github.com/Boxline-dev/sdk-node. The package on npm: @boxline/sdk.
This page describes version 3.0.0.
Create a client
The client reads BOXLINE_API_KEY and BOXLINE_API_URL from the environment. Without BOXLINE_API_URL it uses the public API, like the CLI. For an API on your own computer, set it to http://localhost:8080.
export BOXLINE_API_KEY="your-api-key"export BOXLINE_API_URL="https://api.boxline.dev"import { Boxline } from "@boxline/sdk";const bx = new Boxline(); // reads BOXLINE_API_KEY and BOXLINE_API_URL// Or set the options yourself.const custom = new Boxline({ apiKey: process.env.BOXLINE_API_KEY, baseUrl: "https://api.boxline.dev", maxRetries: 3, timeoutMs: 60_000,});| Option | Default | What it does |
|---|---|---|
apiKey | BOXLINE_API_KEY | Your API key. |
baseUrl | BOXLINE_API_URL, then the public API | The API’s address, https://api.boxline.dev. |
maxRetries | 2 | How often a safe request is tried again. See Retries. |
timeoutMs | 120000 | The time limit of one request, in milliseconds. |
headers | none | Headers sent with every request. |
fetch | the global fetch | Your own fetch function, for tests or tracing. |
credentials | none | For browser apps on Boxline’s own sites that use the console’s login cookie instead of a key. |
Sessions and Playwright
A session is an isolated machine with a browser (the default), a shell, or both, and a /workspace disk they share. Connect Playwright to a browser session’s connectUrl, or drive the same browser with the actions API, one HTTP call per list of actions.
import { Boxline } from "@boxline/sdk";import { chromium } from "playwright-core";const bx = new Boxline();const session = await bx.sessions.create({ timeout: 300 });try { // A signed URL: treat it like a password. const browser = await chromium.connectOverCDP(session.connectUrl!); const page = browser.contexts()[0]!.pages()[0]!; await page.goto("https://example.com"); console.log(await page.title()); // The actions API drives the same browser without Playwright. const { title, content } = await session.content("markdown"); console.log(title, content.length);} finally { await session.stop(); // billing stops here}session.liveUrl is a page where a person can watch and take over. session.stop() saves the browser and files and stops billing; session.resume() brings the session back as it was, and session.delete() deletes it for good. A call on a stopped session does not resume it: it throws SessionNotRunningError.
connectUrl, liveUrl and terminalUrl are signed: anyone who has one can use the session, so treat each like a password. bx.sessions.get(id) returns fresh ones every time, and session.refresh() reads them again into the object you hold. A URL is null where the session has no browser (connectUrl, liveUrl) or no shell (terminalUrl).
Every method that works on one session is on bx.sessions and takes the session id first, such as bx.sessions.stop(id). A Session object has the same methods without the id, such as session.stop(). session.data holds the session as the API last returned it.
Browser, shell, or both
browser is on by default and shell is off. Pick the kind when you create the session. You cannot change it later. See Configuring sessions.
const browserOnly = await bx.sessions.create({}); // the defaultconst shellOnly = await bx.sessions.create({ browser: false, shell: true });const both = await bx.sessions.create({ shell: true });A browser call (actions, computer, runScript, exportCookies, and the session.goto() family) on a shell-only session throws BrowserDisabledError. A shell call (exec, restartShell) on a session without a shell throws ShellDisabledError.
Change a running session
session.update() is the one call for every change to a running session. It takes the field names and shapes sessions.create has, and the session object holds the answer afterwards.
const session = await bx.sessions.create({ timeout: 300, proxy: { type: "residential", country: "DE" } });await session.update({ timeout: 900 }); // its length in seconds from its last start; expiresAt movesawait session.update({ idleTimeout: 120 }); // end it after 2 minutes without activityawait session.update({ rotateProxy: true }); // a new proxy IP now; open browser connections reconnect (400 without a proxy)await session.update({ proxy: null }); // remove the proxyawait session.update({ blockAds: true, cookieBanners: "reject", captcha: "ask" });await session.update({ rotateUrls: true }); // every earlier signed URL stops workingconsole.log(session.liveUrl); // the new one: share it only with people who may use ittimeoutis at least 60 seconds from now and at most your plan’s maximum (a longer value throwsPlanLimitError). A longer session bills for longer: setidleTimeoutso a session whose client crashed ends by itself.rotateUrls: trueis for a URL that leaked, or for a person who should lose access. Every earlierconnectUrl,liveUrlandterminalUrlstops working at once, so a Playwright connection made with the old one is closed. The call returns the new URLs.- Fields fixed when the session is created (
browserorshellon or off,viewport,profile,recordSession,setup,extensions,env,credentials) throwNotUpdatableError. The message names the field. - A session that is not running throws
SessionNotRunningError.
See Update a session and the API reference.
Browser, shell and files together
One session has a browser, a shell and a disk they share. Browser downloads land in /workspace/downloads, the shell starts in /workspace, and the files API reads and writes it. This example downloads a report, adds it up with Python, and types the total into a form.
import { Boxline } from "@boxline/sdk";import { chromium } from "playwright";const bx = new Boxline();const session = await bx.sessions.create({ shell: true, keepAlive: true });try { // Browser: download a report. Downloads land in /workspace/downloads. // (A demo page; on your own site, page.goto() your report page instead.) const browser = await chromium.connectOverCDP(session.connectUrl!); const context = browser.contexts()[0]; const page = context.pages()[0] ?? (await context.newPage()); await page.setContent( `<a download="report.csv" href="data:text/csv,${encodeURIComponent("month,revenue\nJan,1200\nFeb,1850\nMar,2410")}">Download report</a>`, ); await page.getByRole("link", { name: "Download report" }).click(); const csv = await session.files.waitFor("downloads/*.csv"); console.log("downloaded", csv.path, csv.size); // Shell: add up the revenue column with Python, on the same machine. const run = await session.exec(`mkdir -p output && python3 -c "import pandas as pd; print(pd.read_csv('downloads/report.csv')['revenue'].sum())" > output/total.txt`); if (run.exitCode !== 0) throw new Error(run.stderr); // Files: read the result. const total = (await session.files.readText("output/total.txt")).trim(); // "5460" // Actions: type it into the form and send it. const results = await session.actions([ { action: "goto", url: "https://httpbin.org/forms/post" }, { action: "fill", selector: "input[name=custname]", value: total }, { action: "click", selector: "form button" }, { action: "content", format: "text" }, ]); console.log(results.at(-1)?.value?.content); // what the form received await browser.close();} finally { await session.stop();}The same mix works inside an agent run and inside a script. See Browser, shell and files.
Files, also after a session stops
Reading a file, listing a folder and downloading a folder work on a stopped session too, until it is deleted. No machine starts. Writing, deleting and waiting for a file need a running session.
import { writeFile } from "node:fs/promises";const tar = await session.files.archive("output"); // a folder as one .tar.gz (a Uint8Array)await writeFile("output.tar.gz", tar);await session.stop();console.log(await session.files.readText("output/total.txt")); // still readable while stoppedconst entries = await session.files.list("output");A folder over 1 GiB or 100,000 entries throws ArchiveTooLargeError before any byte is sent. A stopped session that saved nothing throws NothingSavedError. See File uploads and downloads.
The page APIs
One call each. The page loads in a real browser inside a sandbox.
const page = await bx.fetch("https://example.com", { format: "markdown", links: true });const png = await bx.screenshot("https://example.com", { fullPage: true }); // Uint8Arrayconst pdf = await bx.pdf("https://example.com", { paper: "A4" });const { data } = await bx.extract<{ plans: { name: string; price: string }[] }>({ url: "https://example.com/pricing", prompt: "The name and monthly price of every plan",});const started = await bx.crawl.start({ url: "https://example.com/docs", maxPages: 20 });const crawl = await bx.crawl.wait(started.id); // every page in crawl.dataA page that cannot load throws PageUnreachableError (502) or PageTimeoutError (504), and a PDF address NotAWebPageError (422) from fetch and extract. With several urls, extract lists a page that did not load in pages, with error, instead of throwing. See Web.
Web search
const { results, cached } = await bx.search({ query: "playwright connectOverCDP", limit: 5, recency: "year" });for (const r of results) console.log(r.title, r.url, r.publishedAt ?? "");// fetch: also open the top pages (true = 3, or 0 to 5) and get them as Markdown.const withPages = await bx.search({ query: "playwright connectOverCDP", limit: 3, fetch: 2 });console.log(withPages.results[0].content?.slice(0, 200), withPages.results[0].error);The same search within an hour comes from the cache (cached: true) and is not counted. The query goes to Brave, a search provider: keep passwords and personal data out of it. See Search.
Mouse, keyboard and computer use
Coordinates are CSS pixels of the page (1280 by 720 by default). Everything acts on the page through the browser, never on the machine’s desktop, and moves in straight lines.
const s = await bx.sessions.create();await s.goto("https://example.com");await s.mouse.move(400, 300, { steps: 10 });await s.mouse.click(400, 300, { button: "right", count: 2, modifiers: ["Shift"] });await s.mouse.drag("#card", "#done-column", { steps: 20 }); // selectors: the element's centreawait s.mouse.drag([{ x: 100, y: 100 }, { x: 200, y: 150 }, { x: 300, y: 100 }]); // or a pathawait s.mouse.down();await s.mouse.up();await s.hover("#menu");await s.keyboard.key("ControlOrMeta+A");console.log(await s.cursor()); // { x, y }const shot = await s.screenshot({ maxWidth: 640, cursor: true }); // with width, height, scale// Every result says what happened; a bare string is a plain-English step.const results = await s.actions([{ action: "goto", url: "https://example.com" }, "click More information"]);console.log(results.map((r) => r.text));// One action exactly as a computer-use model gave it, and the screen after it.const screen = await s.computer({ action: "left_click", coordinate: [512, 300] }, { maxWidth: 1024 });A point outside the page throws OutOfViewportError. See Mouse and keyboard and Computer use, which has a loop for each provider. For the agent, bx.agent.run({ task, mode: "computer" }) works in computer mode.
An agent run with variables
The model sees %email% and %password%, never the values. A value is typed only where the model types text or picks an option. origins limits it to fields on those sites; use it for passwords.
const run = await bx.agent.run({ task: "Sign in to https://example.com with %email% and %password%, then open the billing page.", variables: { email: "ada@example.com", password: { value: process.env.SITE_PASSWORD!, origins: ["https://example.com"] }, },});for await (const event of bx.agent.stream(run.id)) { if (event.type === "thought") console.log("…", event.text); // what it is about to do if (event.type === "tool") console.log(event.name, event.input); // placeholders, never values if (event.type === "done") console.log(event.status, event.result);}bx.agent.wait(id) polls until the run ends. A run works in its own session. Describe it with session, which takes the fields sessions.create takes. A run in a session without a browser has no browser tools. See AI agent.
// A shell-only run: no browser, so the AI gets the shell and the files only.const run = await bx.agent.run({ task: "Convert every video in /workspace to MP4 with ffmpeg and list the new files", session: { browser: false, shell: true },});const done = await bx.agent.wait(run.id);console.log(done.status, done.result);session and sessionId together are refused. See the run’s session and shell-only runs.
Pause and resume a run
bx.agent.pause(id) lets the AI finish its step and wait (status paused). A person can then act in the session’s live view or terminal. bx.agent.resume(id) goes on: the same run, with the same id, and its steps and usage add up. Resume also carries on a run that stopped early, at a limit or after a server restart. Its resumable.until says how long that works.
const run = await bx.agent.run({ task: "Download the latest invoice from https://example.com/billing" });await bx.agent.pause(run.id, "I will sign in myself"); // the AI finishes its step, then waitsconst session = await bx.sessions.get(run.sessionId);console.log(session.liveUrl); // a signed link: share it only with that personawait bx.agent.resume(run.id, { note: "I signed in" }); // the same run goes onconst done = await bx.agent.wait(run.id);if (done.resumable) { // It stopped at a limit. Give it more steps; the work so far is kept. await bx.agent.resume(run.id, { maxSteps: 60, note: "The invoice page is already open" });}A run that had variables needs their values again when it resumes after a limit. A run that is neither paused nor stopped early throws NotResumableError. See Pause and resume.
A script with useModel
runScript runs Playwright code inside the session, so it needs shell: true. In the script, page, context, browser, env, require, and the plain-English helpers step(), extract() and useModel() are ready to use.
const session = await bx.sessions.create({ shell: true });const { exitCode } = await session.runScript( ` useModel("claude-haiku-5-5"); // every later step() and extract() uses it await page.goto("https://example.com/signup"); await step("type %email% into the email field"); await step("click Continue", { model: "claude-sonnet-5-5" }); // this call only const plans = await extract("the plan names and prices"); // returns the data itself console.log(JSON.stringify(plans)); `, { env: { email: "ada@example.com" }, onData: (_stream, text) => process.stdout.write(text) },);console.log("exit code", exitCode);await session.stop();See Scripts.
CAPTCHAs
By default (captcha: "ask"), Boxline notices a CAPTCHA that waits for a person and hands over. Agent runs and steps pause, session.data.attention says which CAPTCHA, and a person solves it in the live view. "ignore" carries on. "solve" (paid plans) tries to solve it first. Only automate sites you are allowed to.
const session = await bx.sessions.create({ captcha: "ask" });const stop = session.onCaptcha(({ state, kind, url }) => { // Tell someone who may solve it; session.liveUrl is a signed link, so share it privately. if (state === "detected") console.log(`${kind} on ${url}: solve it in the live view`);});await session.goto("https://example.com/signup");await session.waitForHuman({ timeoutMs: 5 * 60_000 }); // throws CaptchaTimeoutError if nobody solves itstop();See CAPTCHAs.
Browser settings and extensions
Ad and tracker blocking and cookie-banner answers are on every plan. Set them when a session starts, or change them while it runs:
const s = await bx.sessions.create({ blockAds: true, cookieBanners: "reject" }); // "reject" is the defaultawait s.goto("https://example.com");await s.refresh();console.log(s.data.blockedRequests); // refused inside the machine, before any proxyawait s.update({ blockAds: false, cookieBanners: "off" });await bx.fetch("https://example.com", { blockAds: true }); // screenshot, pdf, extract, crawl.start and agent.run tooChrome extensions (Manifest V3, Hobby plan and up): upload the zip once, then start sessions with it.
const ext = await bx.extensions.upload("./my-extension.zip"); // or its bytes, at most 10 MBconst withExt = await bx.sessions.create({ extensions: [ext.id] }); // loaded before the session is returnedfor await (const e of bx.extensions.list()) console.log(e.id, e.name, e.version, e.permissions);await bx.extensions.delete(ext.id);An extension sees every page and every value typed in the session, agent variables too. Upload only extensions you trust. An agent run with variables in a session with extensions throws VariablesWithExtensionsError unless you pass allowWithExtensions: true. See Ad and cookie-banner blocking and Extensions.
Credentials, 2FA codes and signing in
Save a password once, and sign a session in with one call. When the site emails or texts its 2FA code, save the password with codeSource: "push" and send each code with credentials.pushCode while the run waits for it. With codeSource: "url" and a codeUrl, the platform asks your endpoint instead, and verifyWebhook checks each request.
import { CredentialCodeTimeoutError, CredentialLoginFailedError } from "@boxline/sdk";await bx.credentials.create({ name: "SHOP", type: "password", origins: ["https://shop.example.com"], username: "ops@example.com", password: process.env.SHOP_PASSWORD!, codeSource: "push", // your system sends each code or sign-in link});const session = await bx.sessions.create();try { // login waits as long as the server may (a code from your system included). const page = await session.login("SHOP", { url: "https://shop.example.com/signin" }); console.log(page.url, page.title, page.runId);} catch (err) { if (err instanceof CredentialLoginFailedError) console.log("could not sign in; run", err.runId); // also the timeout else if (err instanceof CredentialCodeTimeoutError) console.log("no code arrived in time"); else throw err;}// In your webhook receiver, when credential.code_needed arrives (the run is waiting):await bx.credentials.pushCode("SHOP", { code: "123456" }); // what the site just emailed or textedcredentials.update(name, { rotateCodeUrlSecret: true }) makes a new signing secret for a codeUrl and returns it once. The old secret stops at once, so put the new one into your endpoint straight away. See Credentials.
Webhooks
import { verifyWebhook, WebhookSignatureError } from "@boxline/sdk";const endpoint = await bx.webhooks.create({ url: "https://example.com/webhooks/boxline", events: ["session.stopped"] });// endpoint.secret ("whsec_…") is shown only now: store it with your other secrets.// In your receiver: the raw body, the Boxline-Signature header, and the secret (or [new, old] after a rotation).try { const event = verifyWebhook(body, header, process.env.BOXLINE_WEBHOOK_SECRET!); // 5-minute window // drop event.id if you have handled it already, then answer 200} catch (err) { if (err instanceof WebhookSignatureError) console.log(err.reason); // answer 400}for await (const d of bx.webhooks.deliveries(endpoint.id, { status: "failed" })) console.log(d.id, d.errorCode);await bx.webhooks.test(endpoint.id);webhooks.update(id, { rotateSecret: true }) makes a new signing secret and returns it once. The old one keeps signing for 24 hours, so give verifyWebhook both while you switch. retryDelivery(id, deliveryId), update and delete do the rest, and webhookSignatureHeader(secret, body) signs bodies for your receiver’s tests. See Rotate a signing secret and Webhooks.
Project settings
const settings = await bx.project.settings(); // { captchaDefault, captchaDefaultEffective, trajectories, updatedVia, … }console.log(settings.trajectories); // { enabled, noticeSeenAt, decidedBy }await bx.project.setSettings({ captchaDefault: "ignore" }); // for new sessions and runs without captchaawait bx.project.setSettings({ trajectories: { enabled: false } }); // the Trajectories program, for this projectSee Project settings and Trajectories.
Usage
bx.usage() is the one call for what the project used and spent. It covers this month so far, or the period you pass. running and concurrencyLimit show how close you are to your plan’s limit on sessions at once.
const u = await bx.usage({ from: "2026-10-01", to: "2026-10-07" });console.log(`${u.running} of ${u.concurrencyLimit} sessions running`);console.log(u.sessions, u.agentRuns, u.modelCostUsd, u.ownKeyModelCostUsd, u.costUsd);for (const day of u.byDay) console.log(day.date, day.sessions, day.agentRuns, day.costUsd);See Pricing.
Lists and pages
Every list takes limit and after. Await it for one page. Iterate it with for await for every item: the SDK follows next for you.
const page = await bx.sessions.list({ status: ["RUNNING", "STOPPED"], limit: 50 });console.log(page.data.length, page.total, page.next);for await (const s of bx.sessions.list({ status: "RUNNING", kind: "shell" })) console.log(s.id); // shell-only sessionsfor await (const e of bx.sessions.events("SESSION_ID", { types: ["console", "error"] })) console.log(e.text);for await (const p of page.iterPages()) console.log(p.data.length);The lists are sessions.list, sessions.events, sessions.pages, profiles.list, apiKeys.list, agent.list and crawl.list. A crawl’s pages come from crawl.get(id, { after }), or all of them from for await (const p of bx.crawl.pages(id)). See Pagination.
Errors
Every failure throws a BoxlineError with status, code (stable, in snake_case), message, requestId and clientRequestId. Quote the requestId when you ask for support. Catch a subclass to handle one case:
| Class | Status | When |
|---|---|---|
RateLimitError | 429 | rate_limited or concurrency_limit. retryAfter has the seconds to wait. |
FeatureNotInPlanError | 402 | feature_not_in_plan: your plan does not include it. |
ProjectSuspendedError | 403 | project_suspended: the message says why. |
IdempotencyMismatchError | 422 | idempotency_mismatch: the key was used for another request. |
IdempotencyInProgressError | 409 | idempotency_in_progress: the first request with the key is still running. Retried by itself. |
InvalidCursorError | 400 | invalid_cursor: after is not a next value of that list. |
CaptchaTimeoutError | 409 or 408 | captcha_timeout: a CAPTCHA kept waiting for a person (409 from a step, 408 from waitForHuman). |
PageUnreachableError | 502 | page_unreachable: the page could not be loaded (fetch, screenshot, pdf, extract). |
PageTimeoutError | 504 | page_timeout: the page did not finish loading within timeoutMs. |
NotAWebPageError | 422 | not_a_web_page: the address is a PDF, which has no page text (fetch, extract, and the pages search and crawl open). Never retried. |
ModelRefusedError | 422 | model_refused: the model declined to extract from these pages (extract). |
SearchUnavailableError | 503 | search_unavailable: web search is not set up, or its provider did not answer. Never repeated by the SDK. |
OutOfViewportError | 400 | out_of_viewport: a mouse or computer-use action points outside the page. |
WebhookUrlNotAllowedError | 400 | webhook_url_not_allowed: a webhook URL that is not public HTTPS. |
WebhookDisabledError, PayloadExpiredError | 409 | webhook_disabled, payload_expired: a delivery that cannot be sent again. |
WebhooksUnavailableError | 503 | webhooks_unavailable: webhooks are not set up on this API server. |
WebhookSignatureError | none | From verifyWebhook: reason is malformed, timestamp_out_of_range or no_matching_signature. |
NotResumableError | 409 | not_resumable: the run is neither paused nor stopped early (it is running, completed, canceled, failed for another reason, or its resume window passed). |
RunNotLiveError | 409 | run_not_live: the run's API server stopped; resume it once it shows as failed with server_restarted. |
TooManyMessagesError | 409 | too_many_messages: the run already got 50 messages. |
SessionNotRunningError | 409 | session_not_running: the session is stopped or deleted. A call on a stopped session does not resume it: call resume first. Reading its files works while it is stopped. |
NothingSavedError | 409 | nothing_saved: resume, or reading the files of a stopped session, when it saved nothing (its machine was lost before its first checkpoint). Start a new session. |
BrowserDisabledError | 409 | browser_disabled: a browser call (actions, computer, runScript, exportCookies, login) on a session created with browser: false. |
ShellDisabledError | 409 | shell_disabled: a shell call (exec, restartShell, runScript) on a session created without shell: true. |
NotUpdatableError | 400 | not_updatable: sessions.update named a field fixed at create (browser or shell on or off, viewport, profile, recordSession, setup, extensions, env, credentials). The message names it. |
ArchiveTooLargeError | 413 | archive_too_large: the folder is over 1 GiB or 100,000 entries. Nothing was sent: download smaller folders, or single files. |
ArchiveBusyError | 429 | archive_busy: two archives of this session are being made already. A kind of RateLimitError: try again when one is done. |
InvalidPathError | 400 or 403 | invalid_path: a file path outside the workspace, or through a link. |
NotADirectoryError | 400 | not_a_directory: files.archive of a path that is a file. |
VariablesWithExtensionsError | 400 | variables_with_extensions: an agent run with variables in a session with extensions, without allowWithExtensions. |
MissingVariablesError | 400 | missing_variables: a task run (or its schedule) has no value for a variable without a default. |
CredentialExistsError | 409 | credential_exists: the project has a credential with that name; change it with credentials.update. |
CredentialNotAllowedError | 400 | credential_not_for_ai, credential_not_for_shell: the credential's scope does not allow this use. |
CredentialCodeTimeoutError | 408 | credential_code_timeout: no 2FA code or sign-in link arrived within codeTimeoutSeconds (session.login, typeCredential; boxline-otp exits with 1). |
CredentialLinkWrongSiteError | 400 | credential_link_wrong_site: a sign-in link is not on one of the credential's sites, so it was not used. |
CredentialLoginFailedError | 422 | credential_login_failed: session.login could not sign in. runId is the run that tried (bx.agent.get(runId) has its steps). |
CredentialLoginTimeoutError | 408 | credential_login_timeout: session.login ran out of time and its run was canceled. A kind of CredentialLoginFailedError. |
CodeUrlNotAllowedError | 400 | code_url_not_allowed: a credential's codeUrl is not a public HTTPS address. |
TooManyCredentialValuesError | 409 | too_many_credential_values: the session already hides 256 earlier values. Start a new session. |
MachineTooOldError | 409 | machine_too_old: during a platform update, the session's machine cannot take env or credentials. |
PlanLimitError | 402 | plan_limit: the plan allows no more of this (tasks, schedules switched on, credentials, browser profiles, searches on Free, a longer session). |
InvalidExtensionError | 400 | invalid_extension: the zip is not an acceptable Manifest V3 extension; the message says why. |
ExtensionDeniedError | 403 | extension_denied: the platform does not allow this extension. |
CrossSiteRequestError | 403 | cross_site_request: a console-login upload that did not come from the platform's own sites. |
LimitReachedError | 409 | limit_reached: the project has as many as it may (100 extensions). |
PayloadTooLargeError | 413 | payload_too_large: the body is too large (an extension zip over 10 MB). |
AuthenticationError | 401 | A missing or wrong API key. |
NotFoundError | 404 | No such thing in this project. |
BoxlineConnectionError | none | No answer came back (the network failed). Retried by itself where safe. |
BoxlineTimeoutError | none | The request took longer than its time limit. |
import { Boxline, BoxlineError, FeatureNotInPlanError, RateLimitError } from "@boxline/sdk";const bx = new Boxline();try { const session = await bx.sessions.create({ shell: true }); try { console.log((await session.exec("python3 --version")).stdout); } finally { await session.stop(); // a failure never leaves a session running }} catch (err) { if (err instanceof RateLimitError) console.log("busy, try again in", err.retryAfter, "s"); else if (err instanceof FeatureNotInPlanError) console.log("not on this plan:", err.message); else if (err instanceof BoxlineError) console.log(err.code, err.message, "request", err.requestId); throw err;}A failed single action, such as session.goto(), throws its own code when it has one (captcha_timeout), otherwise action_failed. See Errors and request ids.
Retries
GET requests, and the calls that create or start something (sessions, bulk, agent runs and resuming them, crawls, API keys and browser profiles), are tried again after a network error, a time-out, a 429 or a 5xx. That is 2 retries by default, waiting 0.5 to 8 seconds with some randomness, or as long as Retry-After or RateLimit-Reset say, up to 60 seconds. A longer wait comes back to you as a RateLimitError.
Other POST requests, such as exec, actions and fetch, are never retried, because they could run twice. See Rate limits.
Idempotency keys
The SDK sends a new Idempotency-Key with every create, and the same key on its retries. So a retry never starts a second session or run. To make your own retries safe too, for example after your process restarts, pass your own key, such as a job id:
const session = await bx.sessions.create({ timeout: 600 }, { idempotencyKey: "nightly-report-2026-09-30" });The API remembers a key for 24 hours. The same key with a different body throws IdempotencyMismatchError. See Idempotency keys.
Options for one call
Every method takes the options below as its last argument.
| Option | What it does |
|---|---|
timeoutMs | The time limit of this call. Long calls (exec, files.waitFor, plain-English steps) get the time they need by themselves. |
maxRetries | Retries for this call. |
idempotencyKey | Your own key for a create. |
clientRequestId | Your own id for the request (1 to 64 of A-Z a-z 0-9 . _ : -), sent as X-Client-Request-Id. The API logs it next to its own id, and errors carry it. |
signal | An AbortSignal that stops the call and its retries. |
headers | Extra headers for this call. |
await bx.sessions.get("SESSION_ID", { timeoutMs: 5_000, maxRetries: 0, clientRequestId: "trace-42" });const patient = bx.withOptions({ maxRetries: 6 }); // a copy of the client with other defaultsEvery method
Each method calls one endpoint of the API reference, where every page has a Node example. The Python SDK has the same names in snake_case.
Sessions
| Method | What it does | Endpoint |
|---|---|---|
bx.sessions.create(params) | Start a session: a browser (the default), a shell ({ browser: false, shell: true }), or both ({ shell: true }). Returns a Session. | POST /v1/sessions |
bx.sessions.get(id) | Get a session. Returns a Session with fresh signed connectUrl, liveUrl and terminalUrl each time (null where the session has no browser or shell). | GET /v1/sessions/{id} |
session.refresh() | Read the session again: fresh data, and fresh signed URLs. | GET /v1/sessions/{id} |
bx.sessions.list({ status, kind, q, from, to, sort, limit, after }) | Sessions, newest first; kind is browser, shell or combined. Await it for one page, or iterate it for all. | GET /v1/sessions |
bx.sessions.update(id, { keepAlive, timeout, idleTimeout, userMetadata, proxy, captcha, browser, blockAds, cookieBanners, rotateProxy, rotateUrls }) | The one call that changes a running session. timeout is its length in seconds from its last start. rotateProxy: true gives the proxy a new IP. rotateUrls: true revokes every earlier signed URL and returns new ones. proxy: null removes the proxy. A field fixed at create throws NotUpdatableError. | PATCH /v1/sessions/{id} |
bx.sessions.stop(id) | Save it exactly as it is (the whole browser and its files), free the machine and stop billing. It is kept for your plan's retention days. | POST /v1/sessions/{id}/stop |
bx.sessions.resume(id) | Bring a stopped session back on a fresh machine, as it was. Throws NothingSavedError when nothing was saved. | POST /v1/sessions/{id}/resume |
bx.sessions.delete(id) | Delete it for good, with what it saved, its recording and its logs. It cannot be undone. | DELETE /v1/sessions/{id} |
bx.sessions.bulk(action, ids) | Stop, resume or delete up to 100 sessions: bulk("stop" | "resume" | "delete", ids). | POST /v1/sessions/bulk |
Browser
| Method | What it does | Endpoint |
|---|---|---|
bx.sessions.actions(id, actions, { timeoutMs }) | Run a list of actions (and plain-English strings) in one call; each result has text. | POST /v1/sessions/{id}/browser/actions |
bx.sessions.computer(id, action, { maxWidth, screenshot }) | One computer-use action in Anthropic's or OpenAI's shape, and the screen after it. | POST /v1/sessions/{id}/browser/computer |
session.mouse.move(x, y) / moveBy(dx, dy) / click(x, y) / down() / up() / drag(from, to) | The mouse, in CSS pixels of the page. | POST /v1/sessions/{id}/browser/actions |
session.hover(target) / cursor() | Hover over an element or a point; where the pointer is. | POST /v1/sessions/{id}/browser/actions |
session.keyboard.key(keys) / type(text) / press(key) | Key combinations such as Control+A, typing, one key. | POST /v1/sessions/{id}/browser/actions |
session.goto(url) / click(target) / fill(selector, value) | One action each. They throw when the action fails. | POST /v1/sessions/{id}/browser/actions |
session.type(text) / press(key) / scroll(deltaY) / wait(target) / select(selector, option) | Keyboard, scrolling, waiting and select boxes. | POST /v1/sessions/{id}/browser/actions |
session.elements() / evaluate(expression) / content(format) | The page as a model sees it, a JavaScript value, or the page as text. | POST /v1/sessions/{id}/browser/actions |
session.screenshot({ fullPage, format, quality, maxWidth, cursor }) | A screenshot of the current tab: { data (base64), mimeType }, and width, height and scale with maxWidth or cursor. | POST /v1/sessions/{id}/browser/actions |
session.upload(selector, path) | Put a file from the workspace into a file input. | POST /v1/sessions/{id}/browser/actions |
session.tabs() / newTab(url) / switchTab(index) / closeTab(index) | Tabs. | POST /v1/sessions/{id}/browser/actions |
session.back() / forward() / reload() | History. | POST /v1/sessions/{id}/browser/actions |
session.step(instruction, { variables, credentials, provider, model }) | One plain-English step; credentials lets it type saved credentials as %NAME%, %NAME.password% and so on. | POST /v1/sessions/{id}/browser/actions |
session.typeCredential(name, { field, selector }) | Type a credential into a field, without its value passing through your code (field: username, password or otp). With a codeSource of push or url, otp waits for the code your system sends. | POST /v1/sessions/{id}/browser/actions |
session.login(credential?, { url?, allowWithExtensions? }) | Sign the browser in with a password credential in one call (default: the one the session's profile links). Resolves to { url, title, runId }. Give the call a longer timeoutMs when the credential waits for a code. | POST /v1/sessions/{id}/browser/actions |
session.extract(instruction, { schema, provider, model }) | Structured data from the current page. | POST /v1/sessions/{id}/browser/actions |
bx.sessions.exportCookies(id, path?) / session.exportCookies(path?) | The browser's cookies as a file in the workspace, for curl and wget. Resolves to { path, count }. | POST /v1/sessions/{id}/browser/export-cookies |
Shell, scripts and files
| Method | What it does | Endpoint |
|---|---|---|
bx.sessions.exec(id, command, { timeoutMs, cwd, env, credentials, shell }) / session.exec(command, opts) | Run a shell command; cwd, env and credentials for this command only. A session without a shell throws ShellDisabledError. Start a long job in the background (nohup … > job.log 2>&1 &) and read job.log later. | POST /v1/sessions/{id}/shell/exec |
bx.sessions.execStream(id, command, onData, opts) / session.execStream(command, onData, opts) | Run a command and stream its output as it happens. | POST /v1/sessions/{id}/shell/exec |
bx.sessions.runScript(id, code, { env, timeoutMs, ai, credentials, signal, onData }) / session.runScript(code, opts) | Run a Playwright script inside the session. It needs a browser and a shell. | POST /v1/sessions/{id}/browser/script |
bx.sessions.restartShell(id, name?) / session.restartShell(name?) | Restart a persistent shell. | POST /v1/sessions/{id}/shell/restart |
bx.sessions.files.read(id, path) / readText(id, path) | A file's bytes, or its text. Works on a stopped session too. | GET /v1/sessions/{id}/files |
bx.sessions.files.list(id, path) | A folder's entries. Works on a stopped session too. | GET /v1/sessions/{id}/files |
bx.sessions.files.archive(id, path?) / session.files.archive(path?) | A folder and everything in it as one .tar.gz, as a Uint8Array (the whole workspace without a path). Works on a stopped session too. | GET /v1/sessions/{id}/files/archive |
bx.sessions.files.write(id, path, data) | Write a file. Folders are made as needed. The session must be running. | PUT /v1/sessions/{id}/files |
bx.sessions.files.delete(id, path) | Delete a file. The session must be running. | DELETE /v1/sessions/{id}/files |
bx.sessions.files.waitFor(id, pattern, timeoutMs) | Wait for a finished file, such as a download. The session must be running. | GET /v1/sessions/{id}/files/wait |
Logs and CAPTCHAs
| Method | What it does | Endpoint |
|---|---|---|
bx.sessions.events(id, { types, after, limit }) | The session's events, oldest first. Every shell command is an exec event, whoever ran it: data.by is api, agent (with data.runId), setup or script. nextAfter is where to poll from. | GET /v1/sessions/{id}/events |
bx.sessions.streamEvents(id, { after }) | Events as they happen, with for await. | GET /v1/sessions/{id}/events/stream |
bx.sessions.pages(id) | Pages visited. | GET /v1/sessions/{id}/pages |
bx.sessions.recording(id) | The recording's frames. | GET /v1/sessions/{id}/recording |
bx.sessions.recordingFrame(id, index) | One frame as JPEG bytes. | GET /v1/sessions/{id}/recording/frames/{index} |
session.waitForHuman({ timeoutMs }) | Wait until no CAPTCHA waits for a person. Throws CaptchaTimeoutError after 5 minutes by default. | helper |
session.onCaptcha(handler) → stop() | Be told when a CAPTCHA appears and goes. Stops by itself when the session stops. | helper |
Web, agent, account and usage
| Method | What it does | Endpoint |
|---|---|---|
bx.profiles.create({ name, fromSession?, attach? }) / get(id) / list() / delete(id) | Browser profiles: cookies and local storage that sessions start with. | GET /v1/profiles |
bx.profiles.update(id, { name?, credential? }) | Rename a profile, and/or link the password credential it signs in with (null unlinks it). | PATCH /v1/profiles/{id} |
bx.credentials.create({ name, type, … }) / list() / get(name) / update(name, patch) / delete(name) | Credentials, write-only: a password (origins, username, password, codeSource, totpSecret, codeUrl, codeTimeoutSeconds) or a secret (value). No call returns a value. With a codeUrl, create and update also return codeUrlSecret, once. update(name, { rotateCodeUrlSecret: true }) makes a new codeUrlSecret, shown once; the old one stops at once. | POST /v1/credentials |
bx.credentials.pushCode(name, { code } | { link }) | Send the 2FA code or sign-in link a site emailed or texted to a run, action or boxline-otp that waits for it (codeSource push). Used once, kept 10 minutes. Not retried. | POST /v1/credentials/{name}/codes |
bx.credentials.audit({ name }) | Changes to credentials, and each use, newest first. | GET /v1/credentials/audit |
bx.extensions.upload(zip) / list() / get(id) / delete(id) | Chrome extensions: a path or the zip's bytes; an Idempotency-Key is sent. | POST /v1/extensions |
bx.fetch(url, { format, links, timeoutMs, waitUntil, proxy, browser }) | A page as Markdown, HTML or text. | POST /v1/fetch |
bx.screenshot(url, opts) | A screenshot, as a Uint8Array. | POST /v1/screenshot |
bx.pdf(url, opts) | A PDF, as a Uint8Array. | POST /v1/pdf |
bx.extract({ url | urls, prompt, schema, … }) | JSON from pages. | POST /v1/extract |
bx.search({ query, limit, country, language, recency, safeSearch, fetch, proxy }) | Web search results, and the top pages as Markdown with fetch. | POST /v1/search |
bx.crawl.start(params) / get(id, { limit, after }) / list() / cancel(id) | Crawls. maxPages goes up to your plan's ceiling: 200 on Free, 1000 on paid plans. robots.txt is always obeyed. | POST /v1/crawls |
bx.crawl.pages(id) / wait(id) | Every page of a crawl, or wait for it to finish, then every page. | helper |
bx.agent.run(params) / get(id) / list() / cancel(id) | Agent runs. session is the run's own session, with the fields sessions.create takes (a shell-only run: session: { browser: false, shell: true }); mode: "computer" for computer mode; output (a JSON Schema) for a JSON answer, typed with get<T> and wait<T>. A run that stopped early has resumable: { until }. | POST /v1/agent/runs |
bx.agent.pause(id, reason?) | Pause a run: the AI finishes its step and waits (status paused), so a person can act in the live view or the terminal. Works on every kind of run. | POST /v1/agent/runs/{id}/pause |
bx.agent.resume(id, { note?, maxSteps?, maxCostUsd?, variables? }) | Keep a run going: the same run, with the same id; its steps and usage add up. note is one text for the AI (up to 2,000 characters): what a person did after a pause, or what to do next after a limit. A run that stopped early (a limit, or a server restart) also takes new limits and its variables again. | POST /v1/agent/runs/{id}/resume |
bx.agent.sendMessage(id, text) | Tell a working run something; it reads it at its next step. | POST /v1/agent/runs/{id}/messages |
bx.agent.stream(id) | A run's events as they happen (thought, steps, status…); ends after the done event. | GET /v1/agent/runs/{id}/events/stream |
bx.agent.wait(id) | Poll until the run finishes (a paused run keeps waiting). | helper |
bx.agent.models() | Providers and models. | GET /v1/agent/models |
bx.tasks.create(params) / list() / get(id) / update(id, patch) / delete(id) | Tasks: saved agent runs with variables, an output schema and a schedule (null removes a field on update). | POST /v1/tasks |
bx.tasks.run<T>(id, { variables, sessionId }) / runs(id, { status }) | Run a task now; its run history, newest first. | POST /v1/tasks/{id}/runs |
bx.tasks.waitForRun(run) | Poll until a task run finishes, and return it with its result. | helper |
bx.me() / hasFeature(feature) | Your project, its plan's limits and features; one feature. | GET /v1/auth/me |
bx.project.settings() / setSettings({ captchaDefault, defaultModel, trajectories }) | The project settings: the CAPTCHA default, the default model (null clears it) and trajectories: { enabled, noticeSeenAt, decidedBy }. setSettings({ trajectories: { enabled } }) turns the Trajectories program on or off. | GET /v1/project/settings |
bx.project.modelKeys() / setModelKey(provider, { key, use }) / deleteModelKey(provider) | Your own model keys: list (never the key), save or switch, delete. | GET /v1/project/model-keys |
bx.webhooks.create({ url, events }) / list() / get(id) / update(id, patch) / delete(id) | Webhook endpoints (the secret only on create). update(id, { rotateSecret: true }) makes a new signing secret, shown once; the old one keeps signing for 24 hours. | POST /v1/webhooks |
bx.webhooks.eventTypes() | Every event type an endpoint can subscribe to. | GET /v1/webhooks/event-types |
bx.webhooks.test(id, { type? }) | Send a test event now, or a sample of one type. | POST /v1/webhooks/{id}/test |
bx.webhooks.deliveries(id, { status }) / retryDelivery(id, deliveryId) | The delivery log, and sending one again. | GET /v1/webhooks/{id}/deliveries |
verifyWebhook(body, header, secret | [secrets]) | Checks a delivery's signature and returns the event (no request). | helper |
bx.apiKeys.list() / create({ name }) / revoke(id) | API keys. | GET /v1/project/api-keys |
bx.usage({ from, to }) | What the project used and spent in a period: sessions and how many run now (running, concurrencyLimit), agent runs and their model cost (agentRuns, modelCostUsd, ownKeyModelCostUsd), seconds, proxy data, CAPTCHA solving and searches, and the same per day in byDay. | GET /v1/project/usage |
bx.pricing() / openapi() / health() | Prices and plans, the OpenAPI description, and a health check. | GET /v1/pricing |
bx.request(method, path, body?, options?) | Any endpoint, returning the parsed JSON. | helper |
bx.send(method, path, body?, options?) | Any endpoint, returning the raw Response (streams). | helper |
bx.bytes(method, path, body?, options?) | Any endpoint, returning the body as a Uint8Array (files, images). | helper |
Prefer another language? Everything the SDKs do works over the plain HTTP API. The Python SDK has the same shape.
These docs describe a beta API. Endpoints, fields and SDK methods may change before general availability; breaking changes will be listed in the changelog.