Steps
Drive the browser one step at a time: exact actions like click and fill, or a plain-English step that a fast model turns into one action.
Steps drive a session’s browser one action at a time, from your own code. There are two kinds, and you can mix them in one request:
- Actions: exact instructions such as
clickorfill, with a selector. Fast and cheap, no AI. - Plain-English steps: “click Sign in”. A fast AI model looks at the page and turns it into one action. Handy when selectors change, or when you do not know them.
Both run next to the browser, inside the session, so a list of steps costs one HTTP request instead of one round trip each.
Actions
POST /v1/sessions/:id/browser/actions with a list of actions. They run in order and stop at the first one that fails. They drive the session’s browser, so a session created with "browser": false answers 409 browser_disabled. A stopped session answers 409 session_not_running: resume it first.
curl -X POST https://api.boxline.dev/v1/sessions/$SESSION_ID/browser/actions \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{ "actions": [ { "action": "goto", "url": "https://example.com/login" }, { "action": "fill", "selector": "#email", "value": "agent@example.com" }, { "action": "click", "selector": "button[type=submit]" }, { "action": "content", "format": "markdown" } ] }'const results = await session.actions([ { action: "goto", url: "https://example.com/login" }, { action: "fill", selector: "#email", value: "agent@example.com" }, { action: "click", selector: "button[type=submit]" }, { action: "content", format: "markdown" },]);// Shortcuts for single actions (they throw when the action fails):await session.goto("https://example.com");const { content } = await session.content("markdown");results = session.actions([ {"action": "goto", "url": "https://example.com/login"}, {"action": "fill", "selector": "#email", "value": "agent@example.com"}, {"action": "click", "selector": "button[type=submit]"}, {"action": "content", "format": "markdown"},])# Shortcuts for single actions (they raise when the action fails):session.goto("https://example.com")content = session.content("markdown")["content"]The response has one result per action that ran:
{ "results": [ { "ok": true, "action": "goto", "value": { "url": "…", "title": "…", "status": 200 }, "ms": 812 }, { "ok": true, "action": "fill", "ms": 35 }, { "ok": true, "action": "click", "ms": 120 }, { "ok": true, "action": "content", "value": { "content": "…", "url": "…", "title": "…" }, "ms": 48 } ]}A failed action has ok: false, an error message and, when there is one, a stable code (for example captcha_timeout). The body may also carry timeoutMs: the time limit of each action, from 1,000 to 120,000 ms (default 30,000).
A goto that cannot connect names the reason when the session’s machine knows it. Behind Chrome’s generic ERR_TUNNEL_CONNECTION_FAILED, the error says why in words:
{ "ok": false, "action": "goto", "error": "page.goto: net::ERR_TUNNEL_CONNECTION_FAILED at https://status.example.invalid/ (the name does not resolve (DNS))" }The result is still ok: false (the SDKs’ one-action methods, such as session.goto(), raise an error with the code action_failed). A machine that does not know says no more than Chrome does. The words are listed in Why a page did not load.
| Action | Fields | What it does |
|---|---|---|
goto | url, waitUntil? | Open a URL in the current tab. Value: { url, title, status }. |
click | selector, or x and y; button?, count?, modifiers? | Click an element (a Playwright selector) or a point on the page, with any button, once, twice or three times. |
fill | selector, value | Set the value of an input. |
type | text, selector?, delayMs? | Type text, optionally into an element first. |
press | key | Press a key or shortcut, e.g. Enter or Control+A. |
scroll | deltaY, deltaX?, x?, y? | Scroll the page, at a point if you give one. |
move, hover, mouse_down, mouse_up, drag, key, cursor | see Mouse and keyboard | Move the pointer, hover, press and release, drag, key combinations, and where the pointer is. |
wait | selector? or ms? | Wait for an element, or a number of milliseconds. |
select | selector, option | Pick an option of a select element, by label or value. |
upload | selector, path | Set a file input to a file inside the session, e.g. /workspace/report.pdf. |
screenshot | fullPage?, format?, quality?, maxWidth?, cursor? | Value: { data (base64), mimeType }, plus width, height and scale with maxWidth or cursor. |
content | format: "markdown" | "html" | "text" | Value: { content, url, title }. |
evaluate | expression | Run JavaScript in the page. Value: what it returns. |
elements | none | The page as a model sees it: title, URL, visible text and numbered interactive elements. |
tabs, newTab, switchTab, closeTab | url?, index? | List, open, switch to and close tabs. |
back, forward, reload | none | History navigation. |
step | instruction, variables?, credentials?, allowWithExtensions?, provider?, model? | One plain-English step. See below. |
extract | instruction?, schema? | Structured data from the current page. See below. |
Every field of every action is in the API reference: Run browser actions. You can mix styles: drive the session with Playwright for complex flows and use actions for quick, latency-sensitive steps.
Every result has a text line saying what happened, such as “Opened https://example.com/ (HTTP 200)”. For the pointer and the keyboard, see Mouse and keyboard. A plain string in the list is a plain-English step: see Plain-English and exact steps together.
Plain-English steps
A step takes one instruction, such as “click Sign in” or “type %password% into the password field”. The page’s visible, clickable elements go to a fast model (by default claude-haiku-5-5 or gpt-6-luna). The model picks exactly one move: goto, click, fill, type, press, select, check, uncheck, hover, scroll, wait, extract, a shell command (in sessions with a shell), or nothing. The platform then does it.
curl -X POST https://api.boxline.dev/v1/sessions/$SESSION_ID/browser/actions \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"actions": [ {"action": "goto", "url": "https://example.com/login"}, {"action": "step", "instruction": "type %email% into the email field", "variables": {"email": "ada@example.com"}}, {"action": "step", "instruction": "click Sign in"} ]}'# Each step's value says what it did, with the same move as a Playwright line (code).await session.goto("https://example.com/login");await session.step("type %email% into the email field", { variables: { email: "ada@example.com" } });const done = await session.step("click Sign in");console.log(done.description, done.code); // what it did, as a Playwright linesession.goto("https://example.com/login")session.step("type %email% into the email field", variables={"email": "ada@example.com"})done = session.step("click Sign in")print(done["description"], done["code"]) # what it did, as a Playwright lineThe value of a step says what it did:
{ "method": "click", "description": "Clicked the Sign in button", "element": { "id": 12, "role": "button", "name": "Sign in" }, "code": "await page.getByRole('button', { name: 'Sign in' }).click();", "url": "https://example.com/login", "title": "Sign in", "model": "claude-haiku-5-5", "usage": { "inputTokens": 1890, "outputTokens": 41, "costUsd": 0.0021 }}code is the same move as a Playwright line, with stable locators and %name% written as env.name. Collect the lines of a good run and you have a script you can run again without AI (see Scripts).
Variables in steps
Use %name% in the instruction and pass the value in variables. Values are filled in on the server and never sent to the model: everything it sees has %name% instead. The session remembers them for later steps. Values are text only. The same limits as agent variables apply, and a session holds at most 50. Unlike agent variables, step variables have no origins limit: make sure the page is the right one before a step types a password.
For passwords, use a saved credential instead: credentials: ["NAME"] on the step lets it type %NAME% for a secret, or %NAME.username%, %NAME.password% and %NAME.otp% for a password, only on the credential’s own sites, and into a shell command only with shell: true. When the password’s codeSource is "push" or "url", a step that types %NAME.otp% waits for the code your system sends (2FA codes and sign-in links). In a session started with a browser profile that links a password credential, that credential works without being listed. The step’s result never shows their values.
curl -X POST https://api.boxline.dev/v1/sessions/$SESSION_ID/browser/actions \ -H "x-api-key: $BOXLINE_API_KEY" \ -H "content-type: application/json" \ -d '{"actions": [{"action": "step", "instruction": "type %SHOP.password% into the password field", "credentials": ["SHOP"]}]}'await session.step("type %SHOP.password% into the password field", { credentials: ["SHOP"] });session.step("type %SHOP.password% into the password field", credentials=["SHOP"])While a step uses a credential, and for at least 60 seconds (at most 75) after the last one, the session takes the same care as during an agent run with variables: no query strings or console text in its log, and its recording pauses. Every server of the platform keeps that care for the whole step, and nothing shortens it. When the model picks a profile's credential the instruction did not name, the step starts that care first and waits about 1.2 seconds before typing, so every server has seen it. An extract after such a step hides those values from the model (the page’s address, title and text) and in its result too. In a session with Chrome extensions, a step with credentials gets 400 variables_with_extensions (and a profile's credential is not offered) unless it sets allowWithExtensions: true.
After a step has typed a credential, the other reads of the page in that session hide it too: elements, content, evaluate and tabs results show the placeholder (%SHOP.password%) where the value is, a screenshot covers the fields that hold it, and errors, script output and exec output hide it. The type action with a credential does the same. This hides a value; it does not protect it. It is a guard against showing a credential by accident, not a boundary: code that runs in the page or the session (evaluate, a Playwright script, a command in the shell) can still read a field and print it changed.
When a tab crashes or hangs
A heavy page can crash its tab, or stop it from answering. An action that finds its tab crashed, or hung (two actions in a row timed out on it), gets a fresh tab on the same address instead, and answers ok: false with code: "tab_crashed" or "tab_hung": it did not run, so send it again on the new tab. The session’s log gets a matching lifecycle event. Agent runs recover the same way: see Crashed and hung tabs.
When a CAPTCHA waits
With the session’s captcha option at "ask" (the default), a step waits up to 4 minutes while a CAPTCHA waits for a person, and the HTTP request stays open meanwhile. Then it fails with code: "captcha_timeout". The SDKs raise that as an error with status 409. See CAPTCHAs.
Extract from the current page
{ "action": "extract", "instruction": "…", "schema": { … } } pulls structured data out of the page the session is on. Give an instruction, a JSON Schema, or both. The value is { data, model, usage }.
const { data } = await session.extract("the plan names and monthly prices", { schema: { type: "object", properties: { plans: { type: "array", items: { type: "object", properties: { name: { type: "string" }, price: { type: "string" } } } }, }, },});console.log(data);result = session.extract( "the plan names and monthly prices", schema={ "type": "object", "properties": { "plans": {"type": "array", "items": {"type": "object", "properties": {"name": {"type": "string"}, "price": {"type": "string"}}}}, }, },)print(result["data"])To extract from pages without a session, use Extract.
Cost and plan
- Actions cost nothing beyond the session’s time.
- Plain-English steps and extract use a model. Each one reports
usage.costUsd, and it counts toward your plan’s monthly spend cap (402 spend_limitpast it). On your own model key there is no charge from us and the cap does not apply; the Free plan runs models on your own keys only. See Your own model keys. - Your plan must include plain-English steps (
402 feature_not_in_plan).
These docs describe a beta API. Endpoints, fields and SDK methods may change before general availability; breaking changes will be listed in the changelog.