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.
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
- user“Cancel every open order over $100 from last week.”
- modelwrites a function body: list the orders, filter them, cancel each one, return the ids
- sandboxruns the body; orders.list runs in your page, orders.cancel is held for approval
- usersees the three cancellations the code wants to make and approves them
- modelreceives the ids and answers
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.
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.
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.
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 },
});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.
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
}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 defaultApprovalRejected into the code without running. Nothing marked runs by omission.pauseone decision per callapprove 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 batchstatus: "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.
This will run 3 operations
- orders.cancel
{ "id": "ord_8f21" } - orders.cancel
{ "id": "ord_8f3a" } - orders.cancel
{ "id": "ord_9012" }
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.
resumeruns 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
ReplayMismatchand nothing runs. - You can approve eight of sixteen. The rest throw at their call site.
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.
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 · onToolCallThe 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.
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
ExecutorcreateCodeTool 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.
import { asExecutor } from '@faustine-ai/browser-sandbox/codemode';
const codemode = createCodeTool({ tools, executor: asExecutor(sandbox) });TanStack AI
isolate drivercreateCodeMode 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.
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.