Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fuzzy-browsers-remember.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"agents": minor
"@cloudflare/think": minor
---

Add opt-in reusable Browser Run sessions for browser tools, including session lifecycle tools, Live View metadata, and Think-native durable session storage.
61 changes: 56 additions & 5 deletions docs/browse-the-web.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Browser tools give your agents full access to the Chrome DevTools Protocol (CDP)
Two tools are provided:

- **`browser_search`** — query the CDP spec to discover commands, events, and types. The spec is fetched dynamically from the browser's CDP endpoint and cached for performance.
- **`browser_execute`** — run CDP commands against a live browser via a `cdp` helper. Each call opens a fresh browser session, executes the code, and closes it.
- **`browser_execute`** — run CDP commands against a live browser via a `cdp` helper. By default, each call opens a fresh browser session, executes the code, and closes it. You can opt into reusable sessions for multi-step workflows.

> **Experimental** — this feature may have breaking changes in future releases.

Expand Down Expand Up @@ -57,6 +57,20 @@ const browserTools = createBrowserTools({

If you need to connect to a custom CDP endpoint instead of the Browser Rendering binding, pass `cdpUrl`.

To preserve tabs, cookies, local storage, and navigation state across tool calls, enable reusable sessions:

```ts
const browserTools = createBrowserTools({
browser: env.BROWSER,
loader: env.LOADER,
session: {
mode: "reuse",
keepAliveMs: 600_000,
liveView: true
}
});
```

### 3. Use with streamText

Pass browser tools alongside your other tools:
Expand Down Expand Up @@ -162,7 +176,8 @@ const stream = chat({
## Execution model

- `browser_search` fetches the live CDP protocol from the browser's `/json/protocol` endpoint and caches it briefly.
- `browser_execute` opens a fresh browser session for the call, exposes a small `cdp` helper API to sandboxed code, and closes the session when execution finishes.
- In the default one-shot mode, `browser_execute` opens a fresh browser session for the call, exposes a small `cdp` helper API to sandboxed code, and closes the session when execution finishes.
- In reusable-session mode, `browser_execute` reconnects to the configured Browser Run session for each call, then disconnects the CDP WebSocket without deleting the browser. Browser state remains available until you close/reset the session or Browser Run times it out.
- LLM-generated code runs in a Worker sandbox. CDP traffic stays in the host worker.

## CDP helper API
Expand Down Expand Up @@ -203,7 +218,7 @@ Clear the debug log buffer.

### `createBrowserTools(options)`

Returns AI SDK tools (`browser_search` and `browser_execute`).
Returns AI SDK tools. In one-shot mode this includes `browser_search` and `browser_execute`. In reusable-session mode this also includes `browser_session_info`, `browser_close_session`, and `browser_reset_session`.

| Option | Type | Default | Description |
| ------------ | ------------------------ | -------- | ------------------------------------------------------ |
Expand All @@ -212,9 +227,45 @@ Returns AI SDK tools (`browser_search` and `browser_execute`).
| `cdpHeaders` | `Record<string, string>` | — | Headers for CDP URL discovery (e.g. Cloudflare Access) |
| `loader` | `WorkerLoader` | required | Worker Loader binding for sandboxed execution |
| `timeout` | `number` | `30000` | Execution timeout in milliseconds |
| `session` | `BrowserSessionOptions` | one-shot | Optional browser session lifecycle |

Either `browser` or `cdpUrl` must be provided. When both are set, `cdpUrl` takes priority.

Reusable sessions require the Browser Rendering binding. `cdpUrl` points at an externally managed browser, so the SDK does not create, list, or delete Browser Run sessions for that mode.

### Reusable session options

```ts
createBrowserTools({
browser: env.BROWSER,
loader: env.LOADER,
session: {
mode: "reuse",
key: "optional-logical-owner",
keepAliveMs: 600_000,
liveView: true,
onSessionInfo(info) {
// Broadcast or render session metadata in your UI.
}
}
});
```

| Option | Type | Default | Description |
| --------------- | ------------------------------------ | ----------- | --------------------------------------------------------------- |
| `mode` | `"reuse"` | required | Reuse one Browser Run session across `browser_execute` calls |
| `key` | `string` | `"default"` | Logical owner key for the reusable session |
| `store` | `BrowserSessionStore` | memory | Storage adapter for the Browser Run session id |
| `keepAliveMs` | `number` | Browser Run | Browser Run inactivity timeout; Browser Run caps this at 10 min |
| `liveView` | `boolean` | `false` | Include Live View URLs in session metadata |
| `onSessionInfo` | `(info: BrowserSessionInfo) => void` | — | Callback whenever session metadata is refreshed |

The default memory store only persists for the lifetime of the returned tool handlers. If you create browser tools per request and want reuse across requests, provide a durable `store`. Think's `@cloudflare/think/tools/browser` wrapper provides Durable Object storage automatically.

`browser_session_info` lists the active targets and returns fresh Live View URLs when `liveView` is enabled. Live View URLs expire after five minutes if they are not opened; call `browser_session_info` again to refresh them.

Call `browser_close_session` when a task is done to stop browser-hour usage and clear browser state. Call `browser_reset_session` when you need a fresh browser.

### Raw access

For custom integrations, import the building blocks directly:
Expand Down Expand Up @@ -249,9 +300,9 @@ Use `cdpUrl` only when you intentionally want to connect to some other CDP-compa

## Current limitations

- **One session per execute call** — each `browser_execute` invocation opens a fresh browser session. Multi-step workflows must be completed within a single code block.
- **One session per execute call by default** — each `browser_execute` invocation opens a fresh browser session unless you opt into reusable sessions.
- **Local development depends on Wrangler support** — if Browser Rendering local mode is unavailable in your environment, upgrade Wrangler or provide `cdpUrl` explicitly.
- **No authenticated sessions** — the browser starts without any cookies or login state. A future Browser Isolation integration could enable user-authenticated sessions.
- **No user-authenticated browser by default** — one-shot sessions start without cookies or login state. Reusable sessions preserve cookies and storage created inside that Browser Run session, but they are not connected to the user's local browser profile.
- Requires `@cloudflare/codemode` as a peer dependency
- Limited to JavaScript execution in the sandbox (no TypeScript syntax)

Expand Down
34 changes: 33 additions & 1 deletion docs/think/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,39 @@ This adds two tools to your agent:

Both tools use the code-mode pattern — the model writes JavaScript async arrow functions that run in a sandboxed Worker isolate. In `browser_search`, the sandbox has access to `spec.get()` which returns the full normalized CDP protocol. In `browser_execute`, the sandbox has access to `cdp.send()`, `cdp.attachToTarget()`, and debug log helpers.

Each `browser_execute` call opens a fresh browser session and closes it when the code finishes. For page-scoped CDP commands (`Page.*`, `Runtime.*`, `DOM.*`), the model must create a target, attach to it, and pass the `sessionId`.
Each `browser_execute` call opens a fresh browser session and closes it when the code finishes by default. For page-scoped CDP commands (`Page.*`, `Runtime.*`, `DOM.*`), the model must create a target, attach to it, and pass the `sessionId`.

For multi-step browsing workflows, enable a reusable Browser Run session:

```typescript
getTools() {
return {
...createBrowserTools({
browser: this.env.BROWSER,
loader: this.env.LOADER,
session: {
mode: "reuse",
keepAliveMs: 600_000,
liveView: true
}
})
};
}
```

In Think, `session: { mode: "reuse" }` automatically uses this agent/chat as the session owner and stores the Browser Run session id in the agent Durable Object. The CDP WebSocket is still opened only for the tool call and disconnected afterward, so this works across hibernation and restarts while the Browser Run session remains alive.

Reusable mode adds three lifecycle tools:

| Tool | Description |
| ----------------------- | -------------------------------------------------------- |
| `browser_session_info` | Return active targets, URLs, titles, and Live View URLs |
| `browser_close_session` | Close the reusable browser and release Browser Run usage |
| `browser_reset_session` | Close any current browser and start from a fresh session |

When `liveView` is enabled, Think broadcasts browser session metadata to connected clients using a `browser-session` message so UIs can render an “Open Live View” action without requiring the model to surface the URL in chat text. Live View URLs expire after five minutes if they are not opened; call `browser_session_info` again to refresh them.

Reusable sessions preserve tabs, cookies, local storage, and page state, but they also consume Browser Run time while alive. Browser Run closes idle sessions automatically, and the tools recover by creating a fresh session if the stored session has expired. Call `browser_close_session` when the browsing task is done.

### Combining with Other Tools

Expand Down
57 changes: 57 additions & 0 deletions packages/agents/src/browser-tests/browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,63 @@ describe("browser tools e2e", () => {
});
});

describe("reusable browser sessions", () => {
afterAll(async () => {
await callAgent("testCloseReuse").catch(() => undefined);
});

it("should preserve browser targets across execute calls", async () => {
const first = (await callAgent("testExecuteReuse", [
`async () => {
const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
const sessionId = await cdp.attachToTarget(targetId);
await cdp.send("Runtime.enable", {}, { sessionId });
await cdp.send("Page.navigate", { url: "data:text/html,<title>Reusable Session</title><body>persisted</body>" }, { sessionId });
for (let i = 0; i < 20; i++) {
const { result } = await cdp.send("Runtime.evaluate", { expression: "document.title" }, { sessionId });
if (result.value === "Reusable Session") break;
await new Promise(r => setTimeout(r, 50));
}
return { targetId };
}`
])) as { text: string; isError?: boolean };

expect(first.isError).toBeFalsy();
const { targetId } = JSON.parse(first.text) as { targetId: string };

const second = (await callAgent("testExecuteReuse", [
`async () => {
const { targetInfos } = await cdp.send("Target.getTargets");
const target = targetInfos.find(t => t.targetId === ${JSON.stringify(targetId)});
if (!target) return { found: false };
const sessionId = await cdp.attachToTarget(target.targetId);
const { result } = await cdp.send("Runtime.evaluate", { expression: "document.title" }, { sessionId });
return { found: true, title: result.value };
}`
])) as { text: string; isError?: boolean };

expect(second.isError).toBeFalsy();
expect(JSON.parse(second.text)).toEqual({
found: true,
title: "Reusable Session"
});

const info = (await callAgent("testReuseInfo")) as {
text: string;
isError?: boolean;
};
expect(info.isError).toBeFalsy();
const parsedInfo = JSON.parse(info.text) as {
sessionId?: string;
targets?: Array<{ id: string; devtoolsFrontendUrl?: string }>;
};
expect(parsedInfo.sessionId).toBeTruthy();
expect(parsedInfo.targets?.some((target) => target.id === targetId)).toBe(
true
);
});
});

describe("integration scenarios", () => {
it("should navigate to a page and get its title", async () => {
const result = (await callAgent("testExecute", [
Expand Down
31 changes: 31 additions & 0 deletions packages/agents/src/browser-tests/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,29 @@ type Env = {
};

export class BrowserTestAgent extends Agent<Env> {
#reuseHandlers?: ReturnType<typeof createBrowserToolHandlers>;

#getHandlers() {
return createBrowserToolHandlers({
browser: this.env.BROWSER,
loader: this.env.LOADER
});
}

#getReuseHandlers() {
this.#reuseHandlers ??= createBrowserToolHandlers({
browser: this.env.BROWSER,
loader: this.env.LOADER,
session: {
mode: "reuse",
key: "browser-test",
liveView: true,
keepAliveMs: 600_000
}
});
return this.#reuseHandlers;
}

@callable()
async testSearch(code: string): Promise<ToolResult> {
return this.#getHandlers().search(code);
Expand All @@ -24,6 +40,21 @@ export class BrowserTestAgent extends Agent<Env> {
async testExecute(code: string): Promise<ToolResult> {
return this.#getHandlers().execute(code);
}

@callable()
async testExecuteReuse(code: string): Promise<ToolResult> {
return this.#getReuseHandlers().execute(code);
}

@callable()
async testReuseInfo(): Promise<ToolResult> {
return this.#getReuseHandlers().sessionInfo();
}

@callable()
async testCloseReuse(): Promise<ToolResult> {
return this.#getReuseHandlers().closeSession();
}
}

export default {
Expand Down
46 changes: 45 additions & 1 deletion packages/agents/src/browser/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import type { ToolSet } from "ai";
import { z } from "zod";
import {
createBrowserToolHandlers,
hasReusableBrowserSession,
SEARCH_DESCRIPTION,
SESSION_INFO_DESCRIPTION,
CLOSE_SESSION_DESCRIPTION,
RESET_SESSION_DESCRIPTION,
EXECUTE_DESCRIPTION,
type BrowserToolsOptions
} from "./shared";
Expand Down Expand Up @@ -36,7 +40,7 @@ export type { BrowserToolsOptions } from "./shared";
export function createBrowserTools(options: BrowserToolsOptions): ToolSet {
const handlers = createBrowserToolHandlers(options);

return {
const browserTools: ToolSet = {
browser_search: tool({
description: SEARCH_DESCRIPTION,
inputSchema: z.object({
Expand Down Expand Up @@ -69,4 +73,44 @@ export function createBrowserTools(options: BrowserToolsOptions): ToolSet {
}
})
};

if (hasReusableBrowserSession(options)) {
browserTools.browser_session_info = tool({
description: SESSION_INFO_DESCRIPTION,
inputSchema: z.object({}),
execute: async () => {
const result = await handlers.sessionInfo();
if (result.isError) {
throw new Error(result.text);
}
return result.text;
}
});

browserTools.browser_close_session = tool({
description: CLOSE_SESSION_DESCRIPTION,
inputSchema: z.object({}),
execute: async () => {
const result = await handlers.closeSession();
if (result.isError) {
throw new Error(result.text);
}
return result.text;
}
});

browserTools.browser_reset_session = tool({
description: RESET_SESSION_DESCRIPTION,
inputSchema: z.object({}),
execute: async () => {
const result = await handlers.resetSession();
if (result.isError) {
throw new Error(result.text);
}
return result.text;
}
});
}

return browserTools;
}
Loading
Loading