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";

export default async function run() {
  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";

export default async function run() {
  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.get is fail-closed: only names you declared in workflow.jsonc'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.

When a site pushes back

Some sites challenge automated traffic. A run navigates, gets an interstitial instead of the page, and everything downstream reads the challenge rather than the content. Three habits keep a workflow out of that path.

Do not browse to a search engine. Search engines challenge automated traffic harder than anything else on the web, and a results page is the one thing an API returns better anyway. Call a search API, then open the browser on the page you actually want:

// Search through an API, browse the result.
const res = await fetch("https://api.tavily.com/search", {
  method: "POST",
  headers: { "content-type": "application/json", authorization: `Bearer ${await secrets.get("TAVILY_API_KEY")}` },
  body: JSON.stringify({ query: "public beaches near Santa Cruz" }),
});
const { results } = await res.json();

const browser = await computer.openBrowser({ startUrl: results[0].url });

Check for a challenge, and stop.An agent handed a challenge page will try to solve it, then try again, and spend the run's budget doing it. Assert in code where the browser landed, and fail with a message that says what happened:

await browser.navigate(target);

const landed = await browser.url();
if (landed.includes("/sorry/") || (await browser.title()).toLowerCase().includes("just a moment")) {
  throw new Error(`blocked by a bot check at ${landed}`);
}

Prefer a signed-in session, and keep it. An authenticated session is trusted where anonymous traffic is not. Log in once in program code, and because the browser survives a wait, reuse that session for the rest of the run instead of signing in again.

Reach for the browser when the work needs one: an app with no API, a flow that only exists in a UI, a page that renders its data in JavaScript. Use an API when one exists, and a browser workflow gets shorter, cheaper, and more reliable at the same time.

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.