Back to API Overview

Cloud Browser

Create and drive cloud browser sessions (navigate, click, type, screenshot, scrape) and embed a live viewer. Backs @willpschulz/scrappy. Requires the browser:read / browser:write API-key scopes. Multi-tenant integrations: if one API key drives browsers for many of your own users, send X-Client-Ref: <your user id> on EVERY browser request. A request that carries it can only see and drive sessions, profiles and sleeping snapshots recorded for that same clientRef — anything else answers 404 exactly as if it did not exist, including sessions recorded with no clientRef at all. Create calls take the ref from the header when the body omits it, and reject a body clientRef that disagrees with the header (400). Requests without the header keep the key-wide view, so existing integrations are unaffected until they opt in.

Endpoints (58)

POST/api/browser/sessions
Auth Required

Create Session

Create a Steel browser session. Reaped after 30 minutes idle — any action resets the window, and the keepalive endpoint refreshes it without acting; a 12-hour absolute cap applies as a backstop. Scope browser:write. Capped at STEEL_MAX_SESSIONS_PER_USER (default 10) concurrent per user. screenWidth/screenHeight set the browser's real window and the screen it reports, so a page cannot see a viewport larger than its own screen. Egress: a session that names an end-user — by profileId, or by clientRef/X-Client-Ref — leaves through a dedicated static residential IP chosen from that identity, so the SAME end-user gets the SAME exit on every session, across restarts and after a pin is cleared. clientRef counts because a FIRST sign-in has no profile yet (the profile is created from the capture at the end), and that is the session whose exit matters most. A profile that already carries a pinned exit replays that pin. Anonymous sessions — no profileId and no clientRef — keep the rotating gateway, so a one-off cannot spend the reputation of an IP other people's logins depend on; their exit IP is not stable between sessions. Scrappy records the exit itself at capture time — you never supply it. Profiles captured before 2026-08-25 are pinned to a rotating exit and stay on it until re-captured: one fresh sign-in per platform moves them onto a static IP. If a session fails with UPSTREAM_UNAVAILABLE, retry with rotateEgress:true to keep the login but take a fresh route. When credits are configured, costs CRED_BROWSER_SESSION_COST up front (402 on insufficient balance) plus per-minute runtime metering.

GET/api/browser/sessions
Auth Required

List Sessions

List the caller's live sessions, each with a viewer URL. With X-Client-Ref set, only sessions recorded for that end user are returned. Scope browser:read.

DELETE/api/browser/sessions/{id}
Auth Required

Release Session

Release (close) a session, freeing Steel capacity and stopping the runtime meter. Scope browser:write. Idempotent. The session's state is captured back onto its profile first, so cookies rotated during the session are not lost — pass capture=false to skip that. Skip it when the session is not worth storing: a replay the site rejected is sitting on a sign-in page, and capturing that overwrites a good stored login with a signed-out one. The cookie count can even go UP while the authentication goes away, so nothing downstream notices the loss. Scrappy cannot tell an authenticated session from a signed-out one; a caller that has just checked can. Accepted as ?capture=false or as {"capture": false} in the body. Only an explicit false opts out, and skipping the capture still settles billing and releases the session.

POST/api/browser/sessions/{id}/navigate
Auth Required

Navigate

Navigate the session to an http/https URL. Scope browser:write. SSRF-protected (private/internal hosts blocked). Readiness is read from the document's navigation timing entry, not from readyState — Chromium sets readyState to 'interactive' when parsing ends, BEFORE deferred scripts run (measured: interactive at +91ms, the deferred script's global at +984ms). By default this answers as soon as the navigation COMMITS — the new document exists, document.readyState is still 'loading' and no script has run — so a click issued next can land on server-rendered markup whose handlers do not exist yet, and both the click API and the page report success while nothing happens. Pass waitUntil:'domcontentloaded' to return once deferred scripts and end-of-body bundles have executed (what you want before interacting), or 'load' to also wait for images, fonts and CSS. A readiness wait that runs out returns 200 with timedOut:true and the readyState reached — the page did load, it is just still busy. Failures split by whose problem they are, and the split is machine-readable — branch on error.code and error.retryable, never on the message. 422 NAVIGATION_ERROR (retryable:false) is the page failing to load and retrying changes nothing. 503 UPSTREAM_UNAVAILABLE (retryable:true) is the session's egress route failing to carry the request; nothing is wrong with your call. Scrappy probes the exit before answering and retires it only if it genuinely refuses a tunnel — the same Chromium error is also what a Steel restart looks like, and retiring a working exit for that would move your end user's IP for no reason. When it IS retired, a retry in a NEW session gets a different route; retrying against the same session keeps failing either way, since a session is bound to its exit at launch. If it came from a profile, create the retry with rotateEgress:true. 503 SESSION_RESUMING (retryable:true) is the OPPOSITE instruction and nothing to do with the proxy: Scrappy replaced the machine the browser was on (a deploy, or a suspend/resume) and is rebuilding the session under the SAME id, so retry that same session after retryAfterSeconds rather than creating a new one — it keeps its id, its cookies and its exit IP. Alert on it as 'the provider is restarting', not as an outage. Both of those apply to EVERY session action, not just navigate — a rebuild breaks a click or a screenshot the same way. 503 BROWSER_SERVICE_UNAVAILABLE (retryable:true) means the browser service could not be reached at all, so nothing could be established about the session, including whether it still exists — retry it; it is not a statement that the session is gone.

POST/api/browser/sessions/{id}/screenshot
Auth Required

Screenshot

Capture a screenshot as base64. Four framings, at most one per call: the viewport (the default), fullPage, a single element (ref or selector — scrolled into view and captured whole even when it is taller than the window), or an explicit clip region in page coordinates. Cropping to the element is usually what you want when checking one control or reading one card: a fraction of the pixels, and it cannot be misread as a different part of the page. Naming two framings is a 400 rather than a silent choice between them. Scope browser:write; rate-limited (browserScreenshot).

POST/api/browser/sessions/{id}/recording
Auth Required

Record Session (video)

Record what the session does, as an MP4. action:'start' begins capturing the active page, 'stop' finalises the file and returns a downloadUrl, 'status' reports progress, 'discard' throws it away. GET this same path for the video itself, once stopped. The capture is a CDP screencast, so the page cannot observe it and nothing is injected into it — an in-page MediaRecorder would need a user gesture and would leave getDisplayMedia fingerprints on a session whose whole point is not to look automated. Encoding happens inside the browser container, next to the frames: the alternative pushes every JPEG across the network to one of several app machines, and then the file lives on whichever machine served the start call rather than the one serving the download. Defaults to 5fps at 1280x720 — a click-by-click record at a fraction of the size of real-time video. It stops on its own at maxDurationMs, at maxBytes, or when the session is released; a recording nobody stopped must not be able to fill the disk of a box every session on that machine shares. One recording per session: starting again while one runs returns the running one rather than competing with it. Download before releasing the session — the file lives with the browser that made it. `record` is also a valid step inside /batch, which is how you wrap a whole flow in one call. Scope browser:write; rate-limited (browserRecording, 30/min).

POST/api/browser/sessions/{id}/scrape
Auth Required

Scrape

Return page content in the shape you need. Beyond raw html/text, three simplified formats run server-side over the rendered DOM (so they see post-JavaScript state) and are far cheaper to work with: markdown strips boilerplate down to the main content, skeleton collapses repeated components to one exemplar plus the CSS selector matching all of them, and structured returns JSON-LD, microdata, OpenGraph and embedded framework state. Try structured first — when a site publishes its own data, reading it beats inferring it from markup. Scope browser:write.

POST/api/browser/sessions/{id}/scrape-mode
Auth Required

Scrape Mode

Render pages for extraction instead of for humans. Blocks the resource categories that cost the most and contribute nothing to extracted data (images, media, fonts, trackers), then runs an in-page pass that removes cookie/consent walls, unsticks fixed headers that swallow clicks, completes the animations that keep waits from settling, promotes lazy-loaded data-src URLs to real src attributes, and pauses media. With persist (the default) it is registered as a document-start script, so every later navigation renders the same way. Pair with the simplified scrape formats for the read side. Scope browser:write.

POST/api/browser/sessions/{id}/keepalive
Auth Required

Keepalive

Mark a session as still in use. Reaping is idle-based, so any action already counts as activity and most integrations never need this. Use it when a user is viewing an embedded browser without anything being driven — reading, filling a form, completing MFA — which otherwise looks identical to an abandoned session. Returns the idle window and a recommended refresh interval — poll on that rather than hard-coding one. Takes no request body: no body, an empty body and {} are all accepted. Scope browser:write.

GET/api/browser/profiles
Auth Required

List Profiles

List persistent browser profiles — saved logins (cookies + localStorage) that survive session teardown, letting an agent act on a user's behalf days after they signed in. Pass clientRef to scope to one of your end-users. Stored cookies are never returned. Scope browser:read.

POST/api/browser/profiles
Auth Required

Create Profile

Create an empty profile. It holds no login until you capture one from a live session. Pin proxyUrl and userAgent for any profile you plan to reuse — replaying a session cookie from a new IP and fingerprint each run is the most common reason a persisted login gets challenged. Scope browser:write.

POST/api/browser/profiles/{id}/capture
Auth Required

Capture Login into Profile

Save a live session's cookies and localStorage into the profile. Call this once after your end-user signs in inside the embedded viewer; from then on, sessions created with that profileId start already authenticated. Sessions released with a profileId re-capture automatically. An empty capture is rejected rather than written, so a transient failure cannot wipe a working profile. Scope browser:write.

PATCH/api/browser/profiles/{id}
Auth Required

Update Profile

Change how a profile is routed — name, pinned proxy, pinned user agent — without touching the login it holds. proxyUrl:null un-pins the profile so its next session re-rolls onto a fresh exit; that is the durable fix when a pinned exit is gone for good, while rotateEgress:true on session creation is the per-run one. A later capture through a working exit re-pins the profile automatically. Scope browser:write.

DELETE/api/browser/profiles/{id}
Auth Required

Delete Profile

Permanently delete a profile and the encrypted login it holds. This is the revocation path — call it when an end-user disconnects their account. Scope browser:write.

POST/api/browser/sessions/{id}/check
Auth Required

Check / Uncheck

Set the checked state of a checkbox or radio input. Use this rather than clicking one: a click TOGGLES, so re-running a step that clicked an already-checked box turns it off, which makes an idempotent retry destructive. Scope browser:write.

POST/api/browser/sessions/{id}/hover
Auth Required

Hover

Move the pointer over an element, by ref, CSS selector or coordinates. Menus and tooltips that only appear on hover need this before their contents exist in the DOM at all — reading the page first and finding nothing is the expected result, not a failure. Scope browser:write.

POST/api/browser/sessions/{id}/select
Auth Required

Select Option

Choose option(s) in a native <select> by value, addressed by ref or selector. Sets each option's selected flag and dispatches a change event, so frameworks listening for change see it — assigning value alone does not always propagate. Pass values (array) for a multi-select. Scope browser:write.

POST/api/browser/sessions/{id}/scroll
Auth Required

Scroll

Scroll the page — to a ref or selector, by pixel offset, to top/bottom, or by dispatching a real mouse-wheel at a point. Scrolling to an element is what makes it clickable by coordinates: an off-screen element reports coordinates outside the viewport, and a mouse event dispatched there hits nothing. The offset and to-element modes move the page's own scroller; `wheel` instead scrolls whatever nested scroller sits under (x, y) — a results list, an inner dashboard panel, a virtualized grid — which is the only way to reach a scrollable region you cannot name with a selector. Scope browser:write.

POST/api/browser/sessions/{id}/press
Auth Required

Press Key

Press a key — Enter, Tab, Escape, arrows, or a text character — via CDP key events. Keys go to whatever currently has focus, which after a click or a navigation is frequently not the element you meant, and that failure is silent because the press itself succeeds. Pass ref to focus a specific element first. Scope browser:write.

POST/api/browser/sessions/{id}/evaluate
Auth Required

Evaluate JavaScript

Run JavaScript in the page and return its result. The escape hatch for anything the typed endpoints do not cover — reading a framework's state, calling a page function, extracting a shape no selector expresses. Returns the value by value, so DOM nodes and functions do not survive; return a serialisable summary instead. Scope browser:write.

GET/api/browser/sessions/{id}/dom
Auth Required

Get DOM

A simplified DOM tree for the page. Useful when you need structure rather than semantics — read-page is the better starting point for deciding what to act on, because it reports roles, names and state as a screen reader would, and hands back refs you can act on directly. Scope browser:read.

GET/api/browser/sessions/{id}/console
Auth Required

Console Entries

Buffered console output captured from the page (Runtime.consoleAPICalled). Capture ARMS on your first read of a session: the CDP domain that feeds it is the single loudest automation signal a page can read (Google's sign-in acts on it), so it is not enabled on sessions that never ask for console output. History is NOT lost — Chromium replays the messages it buffered before the domain was enabled — but it arrives asynchronously, so the FIRST read is normally empty and the second carries everything, including lines logged before you ever called this. Measured against production 2026-08-29: a marker logged before the first read was absent from that read and present in the next one. Poll twice before concluding a page logged nothing. Filter before you read: a busy page emits hundreds of lines and every one of them costs you. `contains` is a case-insensitive substring, not a regex — a caller-supplied pattern would run in Scrappy's own process, where a catastrophically backtracking one blocks every other request on that machine. Each read advances your cursor past everything drained, filtered out or not, so the response reports drained/matched/dropped and `peek=true` leaves the cursor alone while you poll for one line. Scope browser:read.

GET/api/browser/sessions/{id}/network
Auth Required

Network Entries

Buffered network activity captured from the page. Shows what the page actually requested and what came back — the difference between 'the form did nothing' and 'the form posted and the server returned 422' is usually only visible here. A single page load is easily 200 requests, so filter: `contains` matches the URL (case-insensitive substring, not a regex), `method` and `type` narrow by verb and resource kind, and statusMin=400 is the fast way to ask what failed (a request still in flight has no status and is not matched). Same cursor rules as the console endpoint — the response reports drained/matched/dropped, and `peek=true` leaves the cursor alone. Scope browser:read.

GET/api/browser/sessions/{id}/devtools
Auth Required

Console + Network

Console and network entries in one response. The same data as the two endpoints separately, in one round trip — worth it when diagnosing a failure, where the useful signal is usually a console error and the request that preceded it read together. Scope browser:read.

POST/api/browser/sessions/{id}/history
Auth Required

History Navigation

Go back, go forward, or reload. Back is not the same as navigating to the previous URL: it replays the entry, preserving form state and scroll position the way a person pressing the button would. Scope browser:write.

POST/api/browser/sessions/{id}/viewport
Auth Required

Set Viewport

Set viewport size and device emulation. Changing this changes what the page renders — responsive layouts move controls, and a selector or ref found at one size can be absent at another, so read the page again after resizing. Scope browser:write.

POST/api/browser/sessions/{id}/iframe
Auth Required

Iframe Interaction

Act inside a same-origin iframe — click, type, or evaluate within the frame. Cross-origin frames are not reachable this way, by browser design rather than by our choice; for those, the frame's own URL usually has to be driven as its own page. Scope browser:write.

GET/api/browser/sessions/{id}/cookies
Auth Required

Get Cookies

All cookies visible to the session. Cookies are credential-grade for a signed-in site — a stored jar grants account access without passing MFA again — so treat a response from here the way you would a password. Scope browser:read.

POST/api/browser/sessions/{id}/cookies
Auth Required

Set Cookies

Inject cookies into the session — how a stored login is replayed. Note that a faithful replay is not the same as being signed in: a platform that binds its session to the IP that created it rejects the cookies from a different exit, and the browser lands on its sign-in page with every cookie present. If that happens, the login is fine and the egress is not. Scope browser:write.

DELETE/api/browser/sessions/{id}/cookies
Auth Required

Delete Cookies

Delete cookies by name and domain, or clear them all. Clearing signs the session out of everything — which is occasionally what you want before a fresh login, and never what you want mid-run. Scope browser:write.

GET/api/browser/sessions/{id}/storage
Auth Required

Read Storage

Read localStorage or sessionStorage. Some sites keep auth state here rather than in cookies, so a login that replays cookies alone can still land signed out — if that happens, this is the place to look before blaming the cookie jar. Scope browser:read.

POST/api/browser/sessions/{id}/storage
Auth Required

Write Storage

Write a localStorage or sessionStorage value. Writing here does not make a site treat you as signed in — apps read their storage at load and cache it, so a value injected after the page is up is usually ignored until a reload. Set it before navigating, or reload after. Scope browser:write.

DELETE/api/browser/sessions/{id}/storage
Auth Required

Clear Storage

Remove one storage key, or clear the store. For a site that keeps auth state in storage rather than cookies, clearing it signs the session out even though every cookie survives — which is a useful way to force a fresh login, and a surprising way to lose one. Scope browser:write.

GET/api/browser/sessions/{id}/context
Auth Required

Session Context

The session's cookies and localStorage, plus the exit it is going out through. Proxy credentials are stripped, and egressIp is best-effort — it is resolved by fetching an echo service from inside the browser, so a page whose CSP blocks that returns null rather than a wrong answer. Worth checking when a replayed login is rejected: if the exit IP is not the one that minted the session, the cookies were never the problem. Scope browser:read.

POST/api/browser/sessions/{id}/captcha
Auth Required

CAPTCHA

Detect or solve a CAPTCHA. Detection distinguishes a challenge that is actually RENDERED from a page merely carrying the vendor's script — most sign-in pages load the script unconditionally, so presence of the code proves nothing. Solving requires a solver key; without one a challenge is reported for a human to clear rather than silently retried. Scope browser:write.

POST/api/browser/sessions/{id}/sleep
Auth Required

Sleep Session

Capture the session's state and release its browser, returning a snapshot id. Stops the per-minute meter while keeping the login: wake restores cookies and the first tab's URL into a fresh session. Use it for a session a person will come back to rather than paying for an idle browser. Scope browser:write.

POST/api/browser/sessions/{id}/release
Auth Required

Release (beacon)

Release the session, shaped for navigator.sendBeacon — a page being closed cannot reliably finish a DELETE, and an abandoned session bills per minute until the reaper catches it. Prefer DELETE /sessions/{id} everywhere a normal request can complete. Scope browser:write.

GET/api/browser/sessions/{id}/devtools-stream
Auth Required

Console + Network (SSE)

Server-sent event stream of console and network entries as they happen, rather than the buffered snapshot the other endpoints return. Use it to watch a page while it is doing something; use /devtools when diagnosing after the fact. Returns text/event-stream, so it is read with an EventSource rather than a JSON client, and it authenticates by bearer token or session cookie like every other browser route. Scope browser:read.

POST/api/browser/sessions/{id}/type
Auth Required

Type

Type text into an input, textarea or contenteditable, identified by a ref from read-page or a CSS selector. Prefer ref: it names the element directly, so nothing has to be guessed and it cannot silently target the wrong node after a re-render. The value is set through the element's own setter and an input event is dispatched, so React and other frameworks that ignore a raw value assignment still see the change, and the response reports the value that actually settled rather than the one that was sent — a masked, formatted or rejected field will differ, and that difference is the useful part. clear:false appends instead of replacing. Scope browser:write.

POST/api/browser/sessions/{id}/read-page
Auth Required

Read Page

The page as an agent should see it: Chrome's own accessibility tree, flattened, with a stable handle per node. Every other way to find an element here starts from a CSS selector the caller has to guess, and a guess fails quietly — it matches a different element after a re-render, or matches nothing because the control is a styled div rather than a real input, or expects an id where a name was given. Each node returns role, accessible name, value, state (disabled, checked, expanded, required, invalid, readonly, selected) and depth. Roles and names come from Chrome's own accessibility computation, so aria-label, label[for], alt text, title and content are already accounted for — the same thing a screen reader would say. `ref` is `ref_<backendNodeId>` and can be passed straight to the click endpoint. Backend node ids are browser-wide rather than scoped to the connection that produced them, so a ref stays valid without any server-side registry; it dies when the page re-renders that node, and a click will say so rather than clicking stale coordinates. Scope browser:read.

POST/api/browser/sessions/{id}/find
Auth Required

Find Elements

Locate elements by what they say, and get refs back. Read Page already returns everything actionable, but 'everything' on a real page is hundreds of nodes, and an agent that only wants the Sign in button pays for the whole tree — in tokens, and in the mistakes that come from reading a long list. This asks Chrome the same question and returns only the rows that answer it, ranked: the accessible name first, then value, description and role. `role` is a filter rather than a preference, which is what stops a query from returning the heading above the button you meant. Refs are the same ref_<backendNodeId> handles Read Page hands out, so a match goes straight to click, type, hover, element-info or screenshot. No matches is a success, not a 404 — the page does not say that, and the next move is a full read-page rather than a retry. Scope browser:read.

POST/api/browser/sessions/{id}/batch
Auth Required

Batch Actions

Run several session actions in one request, in order. This is the biggest speed-up available over this API: every other endpoint is a full round trip — network, auth, ownership lookup — and a login or checkout flow is a dozen of them, all spent waiting on a browser that is already sitting there. Steps run sequentially and never concurrently, because they act on one page where order is the meaning. Each step is charged to the rate-limit bucket its own endpoint uses, so this is a latency win rather than a quota loophole, and each step runs the *same* handler that endpoint runs — nothing here is a second implementation that could drift. stopOnError defaults to true: typing step 5 after step 4 failed to find its field types into whatever had focus instead, which is how a password ends up in a search box. Steps that never ran come back under `skipped`. Supported actions: navigate, click, type, press, hover, scroll, select, check, drag_drop, paste, wait, wait_for_selector, wait_for_network, wait_for_function, read_page, find, element_info, screenshot, record, scrape, scrape_mode, evaluate, history, viewport, iframe, dialog, intercept, captcha, pdf. `wait` exists only here — { action: 'wait', ms: 400 } is how you pause for a menu without paying for another round trip. Upload, the session lifecycle routes and cookies/storage are deliberately absent. Scope browser:write.

POST/api/browser/sessions/{id}/fill-form
Auth Required

Fill Form

Set every field of a form — and optionally submit it — in one request. Batch can already express this as a list of type steps and does the same thing underneath; this exists because a form is the most common multi-step interaction there is, and spelling it as fields removes the two mistakes that actually happen: forgetting to clear a field that already has a value, and reaching for type on a checkbox. The control type is inferred from the value — a string types, a boolean checks, kind:'select' picks an option — and each field reports its own result, so a failure names the field rather than a step index. If a field fails the submit is skipped: a half-filled form that gets posted anyway is worse than one that does not, because the site records the partial attempt and some rate-limit the retry. Scope browser:write.

POST/api/browser/sessions/{id}/element-info
Auth Required

Element Info

Inspect one element: tag, text, value, visibility, enabled/checked state, bounding box and attributes. It also answers two questions a selector alone cannot. When the element is disabled, `gating` reports what is holding it — which fields in the surrounding form are empty, which are empty AND required, which fail constraint validation, and whether it is disabled by the `disabled` property or only by aria (different causes, different fixes). A vendor form can gate its submit on a field it never marks required and renders no error for, so `emptyFields` is often the only available clue and `emptyRequiredFields` will be empty. Separately, `contentDigest` fingerprints what the element currently contains, so reading it before and after an action proves the action CHANGED something: an unchanged digest is proof of a no-op, which a presence check reports as success. `rowCount` is the coarser companion that survives cosmetic churn — a ticking timestamp moves the digest but not the count. Scope browser:read.

POST/api/browser/sessions/{id}/click
Auth Required

Click

Click by CSS selector or coordinates. button:'right' opens context menus; clickCount:2 double-clicks, which is how many data grids enter cell-edit mode. pierce:true also searches open shadow roots — document.querySelector does not cross shadow boundaries, so a control inside a web component is invisible to a normal selector even though it is plainly on screen; set pierce when a selector cannot find something you can see. pierce is also accepted by type, element-info and wait-for-selector. holdMs turns the click into a press and hold, which is the only way past a 'Press & Hold to confirm you are a human' widget: those start a timer on pointerdown and pass only if pointerup arrives after their threshold, so an ordinary click — which releases in the same millisecond it presses — always fails. Note that clearing the gesture is not the same as clearing the challenge; these systems also score the session server-side once the hold completes. Scope browser:write.

POST/api/browser/sessions/{id}/upload
Auth Required

Upload a File

Attach a file to a file input. Send the bytes one of three ways, and name the target input one of two ways. Files go to Steel's session storage and attach via DOM.setFileInputFiles, so bytes never pass through a JS expression — the old approach capped uploads near 1MB. multipart/form-data carries raw bytes (9MB — the same ~10MB body limit as content, but none of it spent on base64; repeat the file field for a multi-select input); sourceUrl lets Steel fetch the file itself (100MB, SSRF-checked like navigate) and is the path for anything larger; content is base64 and is capped near 7MB — it accepts a plain base64 string or a full data:...;base64,... URL, tolerates the line wrapping Ruby's Base64.encode64 and Python's encodestring produce, and refuses anything outside the base64 alphabet rather than decoding it into a corrupt file (the naive decode never throws: it turns a data URL's prefix into garbage welded onto the front of the real bytes and reports success). Exactly one source per request — two is a 400 rather than a silent choice. After attaching, the input is read back and the response reports what it actually holds: Chrome accepts a path that does not exist (leaving a 0-byte file of that name) and a file list longer than a non-multiple input can take (keeping one), both without complaint, so those come back as ATTACH_INCOMPLETE instead of a false success. Scope browser:write.

DELETE/api/browser/tabs
Auth Required

Close or Activate a Tab

Close a tab, or bring one to the foreground with action:'activate'. Web apps open tabs constantly (OAuth consent, payment windows, target=_blank), and without this an agent can create tabs but never dismiss them, so popups accumulate and keep consuming session resources. Scope browser:write.

POST/api/browser/sessions/{id}/wait-for-selector
Auth Required

Wait for Selector

Block until an element is visible (default), present, hidden, or detached. Essential between steps of a write flow — after clicking Edit wait for the form, after saving wait for the confirmation. Polls server-side, so it costs one request instead of many. Returns found:false rather than erroring on timeout. Scope browser:write.

POST/api/browser/sessions/{id}/wait-for-function
Auth Required

Wait for Function

Block until a JavaScript expression evaluates truthy in the page. This is the wait for 'the app is ready', which no selector can express: server-rendered markup is present and visible the moment the document commits, while the bundle that gives it behaviour has not run — so waiting for the button and then clicking it fires a handler that throws a ReferenceError, and every layer still reports success. Wait for what the handler needs instead, e.g. typeof clientModal !== 'undefined'. An expression that THROWS counts as not-yet-true, which is what makes this usable while a page is still booting (an evaluate racing a navigation's context swap fails too); a timeout carries lastError when the final attempt threw — the page's own message, since the expression runs inside a try/catch in the page (CDP alone reports the literal string 'Uncaught' for every failure). Values come back JSON-serialised and capped at 4096 chars, so a predicate cannot ship megabytes per poll. Must be synchronous — evaluation does not await promises, so an async predicate would be truthy on the first poll; that is rejected with 422 INVALID_PREDICATE rather than answered wrongly. Predicate on a boolean (!!document.querySelector('#row')), since values come back JSON-serialised. Polls server-side: one request and one rate-limit charge instead of a dozen evaluate round trips. Returns satisfied:false rather than erroring on timeout. Scope browser:write.

POST/api/browser/sessions/{id}/dialog
Auth Required

Suppress JavaScript Dialogs

Replace alert/confirm/prompt/beforeunload with no-ops for the current page. Call BEFORE a click that might open one: an open dialog blocks its tab, so a Save or Delete firing confirm() makes every later command on that tab time out. Per-document — re-apply after each navigation. Answering an already-open dialog is not offered because a blocked renderer stops servicing CDP commands; if a tab sticks, use another tab or release the session. Scope browser:write.

POST/api/browser/sessions/{id}/drag-drop
Auth Required

Drag and Drop

Drag from one point to another. Each end can be an element (ref or selector) or raw coordinates: use sourceX/sourceY and targetX/targetY for a slider dragged to a position, a resize gutter, or an SVG handle that has a meaningful drop point but no element under it to match. Pass `path` — a list of points — to trace an arbitrary gesture instead (drawing on a canvas, panning a map, swiping a custom control), where the path itself is the input and a straight line between two boxes would be the wrong motion; it overrides source/target. Moves through intermediate points so drag libraries register the motion. Scope browser:write.

POST/api/browser/sessions/{id}/paste
Auth Required

Paste

Paste text and/or an image into an element — the round trip a Ctrl+V makes, for the case a file upload cannot cover. A rich editor (a GitHub comment, a chat composer, a doc) takes a pasted screenshot straight off the clipboard with no file input to target, so the upload endpoint has nothing to attach to; this decodes the image and delivers it as a real paste event with a DataTransfer the editor reads, the same shape most rich editors (ProseMirror, Slate, Draft.js) consume. Give imageBase64 (raw base64 or a data: URL), text, or both. Targets a ref, a selector, or — with neither — the currently focused element. The one thing it cannot satisfy is an editor that gates on a genuine OS-level (trusted) paste, since there is no OS clipboard to read headless. Scope browser:write.

POST/api/browser/sessions/{id}/wait-for-network
Auth Required

Wait for Network Idle

Block until the page stops issuing requests. Essential for single-page apps that render before their data arrives. Polls server-side, so this costs one request instead of dozens of evaluate round-trips. Returns idle:false on timeout rather than erroring. Scope browser:write.

POST/api/browser/sessions/{id}/intercept
Auth Required

Intercept / Block Requests

Block requests by resource type or URL pattern so pages load faster and scrapes cost less bandwidth. action:'stop' clears all blocking. To observe traffic rather than block it, use the network endpoint. Scope browser:write.

POST/api/browser/sessions/{id}/download
Auth Required

Resolve Download

Resolve the file URL behind a link or button (CSV exports, PDFs, attachments) so you can fetch it directly. Scope browser:write.

POST/api/browser/sessions/{id}/pdf
Auth Required

Render PDF

Render the current page to a PDF and return it base64-encoded. Scope browser:write.

GET/api/browser/sessions/{id}/viewer
Auth Required

Session Viewer (HTML)

Returns an HTML page (not JSON) embedding the live browser. Auth by ?token= viewer token (the only option for a cookie-less external iframe), session cookie, or sk_ API key. Scope browser:read.

POST/api/browser/sessions/{id}/viewer-token
Auth Required

Viewer Token

Mint a short-lived (~5 min) signed token bound to {sessionId, userId} that authenticates a cookie-less iframe to the viewer and its screencast WebSocket. Call from a trusted backend (scope browser:read); never expose the API key to a browser. Returns an absolute viewerUrl with the token embedded.