@faustine-ai/browser-sandbox · MIT

Run agent-generated code in your web app, safely.

A JavaScript library that runs model-written code in a Worker inside a sandboxed iframe. The code gets no network, no DOM and no storage: only the functions you define. Calls you mark wait for the user's approval before they run.

npm install @faustine-ai/browser-sandbox
What it does

Code mode for the browser

In code mode the model does not call one tool per operation. It writes a small program that chains the calls it needs, filters, loops, and returns one value. On the server this runs in a sandbox. In the browser it has to run next to the user's session, because that is what the code works with: the app's own client, the page the user is on.

This library is the place that code runs. You define the functions the model may call. The code runs in an isolated worker and can call those functions and nothing else. Each function runs in your page, with the session, and returns its value across the boundary. Functions you mark do not run until the user approves them.

One turn

  1. user“Cancel every open order over $100 from last week.”
  2. modelwrites a function body: list the orders, filter them, cancel each one, return the ids
  3. sandboxruns the body; orders.list runs in your page, orders.cancel is held for approval
  4. usersees the three cancellations the code wants to make and approves them
  5. modelreceives the ids and answers
Why not run it in the page

Where the code runs decides what it can reach

A model that reads untrusted text writes the code. The question is what that code can touch while it runs.

In the page

new Function(code) / eval
Network
yes, with the session
DOM, storage
yes
Can be stopped
no

The code gets everything the page has. Text the model read on the way can run script on your origin.

In a Worker from the page

new Worker(blobUrl)
Network
yes, same origin, cookies on fetch
DOM, storage
no
Can be stopped
yes

Still your origin: fetch carries the session, and the CSP needs worker-src blob:. No app state either.

In this library

Worker inside <iframe sandbox>
Network
no: connect-src 'none'
DOM, storage
no
Can be stopped
yes

The code reaches your app only through the functions you define, which run in your page, one call at a time.

A sandboxed iframe has an opaque origin: no cookies, no storage, same-origin with nobody. Its document carries a CSP with connect-src 'none', and a Worker created inside it inherits that CSP, so the browser refuses the network before any code runs. The worker has no DOM and no navigable location, and terminate() ends it at any point. Other approaches (SES, QuickJS in WebAssembly, an iframe without a worker) and why the library does not use them are in the docs.

How it works

Two calls

There is no server part. In srcdoc mode nothing is served; a page with a strict CSP serves one static document from a route instead.

01

Define the functions the code may call

defineFunctions is the only way a capability enters the sandbox. Each entry is the real function, which runs in your page, plus its policy. A dotted name becomes a path: the code calls sdk.orders.list(input). approval: true marks a function that needs the user's decision.

page.ts
import { createSandbox, defineFunctions } from '@faustine-ai/browser-sandbox';

const sandbox = createSandbox({ frame: { url: '/sandbox' }, warm: true });

const functions = defineFunctions({
  'orders.list':   (input) => api.orders.list(input),
  'orders.cancel': { call: (input) => api.orders.cancel(input), approval: true },
});
02

Run the code

The code runs as the body of an async function with sdk and console in scope. The result has one of five statuses and always carries the logs, the calls made and the timings. toToolOutput(result) turns it into what a chat tool returns to the model.

page.ts
const result = await sandbox.run({
  code,            // the body of an async function, written by the model
  functions,
  approval: { mode: 'collect' },
});

switch (result.status) {
  case 'ok':      return result.value;
  case 'error':   return result.error.message;   // TypeError: … (line 3)
  case 'limit':   return result.error.message;   // the code ran 10000 ms with no call in flight
  case 'aborted': return 'cancelled';
  case 'pending': return askTheUser(result);     // marked calls are waiting, see below
}
Human in the loop

Marked functions run only after the user approves them

Approval is a property of the function, not of the prompt. A function with approval: true never runs by itself, whatever the code does. What happens when the code calls one depends on the mode you run with.

denythe default
The call throws ApprovalRejected into the code without running. Nothing marked runs by omission.
pauseone decision per call
The worker stops at the call and the run clock pauses while the user decides. approve runs it and returns the value; reject throws at the call site.
await sandbox.run({
  code, functions,
  approval: {
    mode: 'pause',
    onApproval: async ({ fn, input }) =>
      (await confirm(fn, input)) ? 'approve' : 'reject',
  },
});
collectone decision per batch
A marked call does not run; the code receives a pending value and continues. Reads keep executing, marked writes keep accumulating. The run is cut when the code observes a pending value or when it ends with calls still pending. You get status: "pending" and the list of calls, with their names and arguments, to show the user. A loop of sixteen deletes is one card with sixteen rows.
let result = await sandbox.run({ code, functions, approval: { mode: 'collect' } });

while (result.status === 'pending') {
  // result.pending = [{ callId, seq, fn, input }, …] — none of them has run
  const decisions = await showCard(result.pending);   // { all: 'approve' } or { approve: [ids], reject: [ids] }
  result = await result.resume(decisions);
}

What the user decides on

Cancel 3 orders

Open orders over $100 from last week. This cannot be undone.

your UI

This will run 3 operations

  • orders.cancel{ "id": "ord_8f21" }
  • orders.cancel{ "id": "ord_8f3a" }
  • orders.cancel{ "id": "ord_9012" }
CancelCancel the 3 orders

An example of what a host renders in its onApproval callback. The library hands you the rows; the card, its wording and its buttons are yours, so the prompt looks like the rest of your app.

  • The rows come from the sandbox, not from the model: the function name and the exact arguments of each call the code tried to make.
  • resume runs the same code again with a replay: calls already made return their recorded result, approved calls execute, rejected calls throw. Nothing runs twice.
  • If on resume the code tries a call that differs from what the user saw, the run ends with ReplayMismatch and nothing runs.
  • You can approve eight of sixteen. The rest throw at their call site.
Approval in the docs
Security

What the sandbox enforces

Each of these is a browser guarantee or a check in the bridge, not a convention the code is asked to follow.

No network

The frame has an opaque origin and a CSP with connect-src 'none'. The worker inherits it: fetch, XMLHttpRequest, WebSocket, EventSource, sendBeacon, importScripts and import() are refused by the browser.

No DOM, no storage

window, document, parent, localStorage, indexedDB and caches do not exist in the worker. location is gone; assigning it navigates nothing.

Only the functions you define

The code sees a tree of the names in defineFunctions and nothing else. A name outside the map runs nothing. __proto__, constructor and prototype are refused as names.

Structured clone across the boundary

Inputs and results travel by structured clone over a private MessageChannel. JSON, Date, Map, Set, typed arrays, File and Blob cross; functions, DOM nodes and class instances do not.

A private channel

The frame accepts one init from its parent and ignores messages without the nonce. The page accepts the handshake only from the frame it created. The code cannot reach the port.

Limits that terminate

A wall clock for the run, a busy-loop watchdog, a cap on calls, input bytes, output bytes and log lines. Any limit terminates the worker; while (true) {} ends in seconds.

Cancellation

An AbortSignal on run ends it with status aborted and terminates the worker, mid-loop if needed.

No fallback to the page

If the frame or the worker cannot start, run resolves SandboxUnavailable. Nothing is ever compiled in the page.

Works under a strict CSP

Serve the frame document from a route (url mode) and your page needs only frame-src for that origin. No 'unsafe-eval', no worker-src.

What it does not do

It does not make your functions safe: what orders.cancel does with its input is your code's responsibility. It is not a sandbox for your page. In srcdoc mode the frame inherits your page's CSP.

Security model
Integrations

Adapters for the AI SDK, Cloudflare codemode and TanStack AI

Faustine built this library for its own code mode in the browser. It fits a custom code mode like that one, the code mode libraries below, or any other system that has to run agent-generated code in the browser. The adapters are small and none of them is a runtime dependency.

AI SDK

useChat · onToolCall

The AI SDK's own code mode runs on the server and reaches server tools only; runCode is the client-side piece, a client tool whose program runs in the user's browser against the page's functions. One entry in the map you build with clientTools next to your other client tools; the two code modes can combine in one chat.

page.ts
import { runCodeTool, clientTools } from '@faustine-ai/browser-sandbox/ai';

const runCode = runCodeTool({
  sandbox, functions,
  approval: { mode: 'collect', onApproval: showCard },
});

const tools = clientTools({ runCode, getLocation: () => geo.current() });
const chat = useChat({ transport, onToolCall: tools.onToolCall });
tools.attach(chat);

Cloudflare codemode

Executor

createCodeTool delegates the run to an Executor. Its browser one is an iframe without a worker: it cannot stop a synchronous loop, needs 'unsafe-inline' and has no approval hook. asExecutor is a third executor for the same createCodeTool: a worker it can terminate, a strict CSP, the approval modes.

page.ts
import { asExecutor } from '@faustine-ai/browser-sandbox/codemode';

const codemode = createCodeTool({ tools, executor: asExecutor(sandbox) });

TanStack AI

isolate driver

createCodeMode runs the program through a pluggable IsolateDriver; the one it ships is QuickJS in WASM, with no approval step. asIsolateDriver is a driver for the same createCodeMode: the page's own engine, structured clone instead of re-encoding File and Blob, about 8 KB instead of a 1 MB payload, the approval modes.

page.ts
import { asIsolateDriver } from '@faustine-ai/browser-sandbox/tanstack';

createCodeMode({ tools, driver: asIsolateDriver(sandbox) });
8.5 KB
core, min+gz
0
dependencies
2–7 ms
boot

Evergreen Chromium, Firefox and Safari 16+. A browser missing a primitive surfaces as SandboxUnavailable; there is no degraded mode.

Faustine builds this library for its Web App Agents, whose code mode runs on the customer's page. It has no dependency on Faustine and is published under MIT so it can be read, tested and reused.

Install

One package, no server part. The five-minute example in the docs runs without serving anything.

npm install @faustine-ai/browser-sandbox