Browser use

A workflow can open a real browser, drive it with plain code, and hand it to an agent. The browser runs inside the run's own machine, and your program owns it: you can navigate, fill a form, or read the page in deterministic code, then give a logged-in browser to an agent() leaf that operates the app from there.

Open a browser you own

computer.openBrowser() returns a session handle. It is a live in-VM browser, not a remote one, and the handle belongs to your program:

import { computer, agent } from "@boardwalk-labs/workflow";

const browser = await computer.openBrowser({ startUrl: "https://app.example.com" });

// Drive it in code...
await browser.navigate("https://app.example.com/reports");
if (!(await browser.url()).includes("/reports")) throw new Error("navigation failed");

// ...or hand it to an agent for the open-ended part.
await agent("Export the Q3 revenue report as CSV and save it.", { session: browser });

await browser.close(); // or let the run reap it at the end

The same browser is shared: the program drives it, the agent drives it, and both see the same tabs and the same session. That is the point of a handle the program holds rather than a browser the agent conjures on its own.

Set it up before the agent sees it

The most common reason to open a browser in code first is to get past the parts an agent should never touch: a login wall, a cookie banner, a workspace picker. Because your program is the trusted layer (the only place a secret lives), it can authenticate with a real credential and then hand over a browser that is already inside the app:

import { computer, agent, secrets } from "@boardwalk-labs/workflow";

const browser = await computer.openBrowser({ startUrl: "https://app.example.com/login" });

// Log in yourself. The password is in the program; it is never put in a prompt.
const password = await secrets.get("APP_PASSWORD");
await browser.eval('document.querySelector("#email").value = "ops@example.com"');
await browser.eval('document.querySelector("#password").value = ' + JSON.stringify(password));
await browser.eval('document.querySelector("form").submit()');

if (!(await browser.url()).includes("/dashboard")) throw new Error("login failed");

// Now the agent gets an authenticated browser, and never saw the credential.
await agent("Find the failing invoice for Acme and download it.", { session: browser });

This split is deliberate. The agent operates the application, but the sharp edges (a password, arbitrary page JavaScript) stay in code the model cannot reach. secrets.getis fail-closed: only names you declared in the workflow's permissions.secrets resolve.

What the program can drive

The session handle exposes the browser to deterministic code. Every method round-trips to the live page, so you can act, assert, and inspect between agent turns:

MethodWhat it does
navigate(url)Go to a URL and wait for it to load.
url(), title()Read where the page is, to branch or assert in code.
eval(js)Run JavaScript in the page and get the result. Program-only, never an agent tool.
screenshot()Capture the page; stored as an artifact and its ref returned.
console(), network()Read console messages and network requests, the highest-signal way to verify a page.
close()Tear the browser down. Idempotent, and automatic at run end if you skip it.

Hand it to an agent

Pass the session to a leaf and the agent gains a set of browser actions bound to that one browser: navigate, click, type, read the page, check the console and network:

await agent("Reproduce the checkout bug from ticket #812, then describe what breaks.", {
  session: browser,
});

The agent works from the page's underlying structure, not from screenshots, so it runs on any model you route to without needing vision. One leaf drives one browser; to work two screens at once, open two sessions and give each to its own parallel agent call.

It survives a wait

The browser lives inside the run's machine, so it is still open, still logged in, and on the same tab when the run resumes from a human gate or a sleep. You can open a browser, log in, pause for an approval that takes a day, and pick up exactly where you left off, with no re-login and no reconnect logic:

const browser = await computer.openBrowser({ startUrl: "https://admin.example.com" });
await signIn(browser);

const ok = await humanInput({
  prompt: "Approve this refund batch?",
  input: { kind: "choice", options: ["Approve", "Cancel"] },
});

// Hours later, on resume: same browser, still signed in.
if (ok.value === "Approve") {
  await agent("Process the approved refunds in the admin panel.", { session: browser });
}

One browser, one run

A session is a per-run resource, not a durable one: it lives and dies within a single run. A program can open several browsers when it needs to (each is real memory, so it is a cost you choose), and any left open are reaped when the run finishes. There is nothing to clean up if a run crashes.

Secrets stay out of the page

Browser use widens what a run touches, so the boundaries matter. Two hold by construction:

  • The model never sees your secrets. Credentials live in program code and are redacted from everything an agent() leaf reads, so a hostile page cannot phish them out of the model.
  • Arbitrary page JavaScript is program-only. eval()is the trusted layer's tool, not the agent's. The model gets structured browser actions, not a way to run whatever code a page suggests.

Each run gets its own isolated machine, so a browsing run cannot reach another run's state. Anything the browser displays, including a credential shown on screen, is part of the run's record and inherits its retention.