Executing code
One call runs one program. Every capability a program gets is named on the request or on the executor that runs it, and the protocol states the same contract in this form and on the wire:
type ExecuteRequest = { code: string providers?: Array<ProviderDescriptor> dispatch?: ToolDispatchFn modules?: ModuleMap timeout?: number | null countHostWaits?: boolean outbound?: OutboundFetchFn | null retainHandles?: boolean}code is the only field you have to fill. Every other one grants a capability or takes one away:
| Field | What it decides |
|---|---|
providers and dispatch |
The tool names the model is told about, and the function that receives every call, declared or not. |
modules |
Which modules the program may import. |
timeout |
The deadline the run clock enforces. |
countHostWaits |
Whether time spent waiting on your host counts against that deadline. |
outbound |
A function of yours handles fetch, null closes the network for that run, and leaving it out lets the executor perform the request itself. |
retainHandles |
Whether a run that returns a request handler keeps its isolate alive afterwards. |
Set most of them once, when you construct the executor. A value on the request wins over the executor’s, and an undefined field means what an absent field means. So an executor built with the network closed still opens it for the one call that passes an outbound function:
const executor = new IsolateExecutor({ timeout: 30_000, outbound: null })
await executor.execute({ code }) // fetch rejectsawait executor.execute({ code, outbound: policy }) // fetch reaches your policyEntry form
Section titled “Entry form”Every program is one arrow expression. Static import declarations of module-map entries may come before it, and no other statement may:
async () => { const users = await store.listUsers()
return users.map((user) => user.name)}Whatever the arrow returns is the result of the run, so that program returns the array of names it built. Usually the arrow returns a promise. But it does not have to: the executor calls the expression and awaits whatever comes back, so () => 6 * 7 and () => Promise.resolve(42) both put 42 in result. Your text becomes the body of a generated entry module: the module that declares the provider proxies, races the call against the run clock and reports the outcome. Whatever you pass has to be an expression that can sit in that position. A named function expression fits, and async function main() { return 42 } produces 42. Statement lists do not, and neither does a trailing semicolon:
| What you pass | What comes back |
|---|---|
async () => 6 * 7 |
result is 42 |
(async () => 6 * 7) |
result is 42 |
async () => 6 * 7; |
error, on the token ';' |
const x = 1; x + 1 |
error, on the token 'const' |
| an import written after the arrow | error, on the token 'import' |
Two more shapes need a line break to write down. A comment above the arrow is not a statement, so the text is still the one expression the form asks for. Put a declaration there instead and the text is a statement list again, reported this time on a word strict mode reserves and not on a token:
await executor.execute({ code: `// Plan: multiply the two numbers and hand the product back. async () => 6 * 7`,})// { result: 42, logs: [] }
await executor.execute({ code: `let served = 0
async () => served`,})// { result: undefined, error: 'Unexpected strict mode reserved word [executor.js:62:1]', logs: [] }V8 composes every one of those texts and appends a position. That position points into the generated module, not at anything you wrote:
Unexpected token ';' [executor.js:62:18]No contract pins Unexpected token ';'. Its wording belongs to the engine and sits outside the failure grammar, so a host that matches on it is matching on a compiler’s phrasing. Read one as a sign that the text you passed did not fit the form. No step rewrites what you pass. So a fenced block straight out of a model arrives at V8 with its backticks still in it:
await executor.execute({ code: '```js\nasync () => 6 * 7\n```' })// { result: undefined, error: '"" is not a function', logs: [] }That "" is the empty string the first two backticks make, called as the tag of the one after it. Normalize model output yourself, or let the dialect adapter do it. normalizeCode from @supolka/cloudflare-codemode-executor covers the shapes a model actually emits:
| What the model emitted | What normalizeCode returns |
|---|---|
```js\nasync () => 6 * 7\n``` |
async () => 6 * 7 |
```javascript\nreturn 6 * 7\n``` |
async () => {\nreturn 6 * 7\n} |
export default async () => 6 * 7 |
async () => 6 * 7 |
6 * 7 |
async () => {\nreturn (6 * 7)\n} |
async function main() { return 6 * 7 } |
async () => {\nasync function main() { return 6 * 7 }\nreturn main();\n} |
Every one of those runs and produces 42. On the native contract, either put that function in front of your model’s output or ask for the arrow in the prompt and check the shape yourself.
Reading the result
Section titled “Reading the result”execute returns a promise for one ExecuteResult:
type ExecuteResult = { result?: unknown error?: string logs?: Array<{ level: 'log' | 'warn' | 'error'; text: string }> executionId?: string handles?: Array<string>}Read error first. When it is undefined the run produced a result. When it is a string the program failed, and result is present with the value undefined, so test the value and never whether the key exists. But the call still resolves. Hand the text back to the model and let it write the next attempt:
await executor.execute({ code: 'async () => 6 * 7' })// { result: 42, logs: [] }
await executor.execute({ code: 'async () => { throw new Error("nope") }' })// { result: undefined, error: 'nope', logs: [] }That nope is the message the program threw, with no wrapper around it. Every throw reports its own message exactly as it reads, so a program that throws a string reports the string and one that throws a plain object reports [object Object]. Where the executor enforced a bound, error is the declared sentence of the failure grammar instead, and those sentences are contract. Failures of the executor itself never arrive in this field: execute rejects with those, and the failures guide tells the two apart. logs is always there. It is empty when the program wrote no lines, and executionId and handles appear only when a run retained its handles.
Values that cross
Section titled “Values that cross”Serialization sits between the isolate and your process, so what you get back is data and never a reference to anything still living inside. Most of the time it is invisible. But a program returning one value of each kind makes it visible:
await executor.execute({ code: `async () => ({ n: 1, s: 'a', b: true, nul: null, u: undefined, arr: [1, 2], bytes: new Uint8Array([1, 2, 3]), buf: new ArrayBuffer(2), f32: new Float32Array([1.5]), d: new Date(0), m: new Map([['a', 1]]), set: new Set([1]), fn: () => 1, re: /x/g, })`,})Your process gets this back:
{ result: { n: 1, s: 'a', b: true, nul: null, arr: [ 1, 2 ], bytes: Uint8Array(3) [ 1, 2, 3 ], buf: ArrayBuffer { [Uint8Contents]: <00 00>, [byteLength]: 2 }, f32: Uint8Array(4) [ 0, 0, 192, 63 ], d: '1970-01-01T00:00:00.000Z', m: {}, set: {}, re: {} }, logs: []}Four kinds of value come back changed or not at all:
| What the program put in the object | What your process gets |
|---|---|
u: undefined and fn: () => 1 |
Neither key at all. |
d: new Date(0) |
The ISO string '1970-01-01T00:00:00.000Z'. |
m, set and re |
{}. A Map, a Set and a RegExp keep none of their contents in enumerable properties. |
f32: new Float32Array([1.5]) |
The four bytes underneath it. A typed array that is not a Uint8Array decays to plain bytes. |
JSON is the carrier, so Uint8Array and ArrayBuffer cross as a tagged base64 envelope and are rebuilt on the far side as themselves, in the result and in both directions of a tool call. Class instances arrive as their own data: new Point() with this.x = 1 and a y getter on the prototype arrives as { x: 1 }. toJSON is honoured, so an object that defines one decides its own wire shape. Two values have no representation at all, so they end the run and never cross:
await executor.execute({ code: 'async () => ({ big: 1n })' })// { result: undefined, error: 'Do not know how to serialize a BigInt', logs: [] }
await executor.execute({ code: 'async () => { const o = {}; o.self = o; return o }' }){ result: undefined, error: 'Converting circular structure to JSON\n' + " --> starting at object with constructor 'Object'\n" + " --- property 'self' closes the circle", logs: []}Both are JSON.stringify refusing, so both are avoidable in the program: build the shape you want on the way out, and turn a BigInt into a string before it leaves. Every message a run pushes across the boundary has a ceiling on it, the result included. Past that ceiling the run fails with a sentence naming the size and the bound, and the worker that ran it stays up. The bounds guide has the number and the reason.
console.log, console.warn and console.error cross to the host as each line is written, and they come back with the result. Every other console method a program might call exists and writes no line, console.debug, console.info and console.table among them. The protocol lists the whole set the sandbox installs. Each line is its arguments passed through String() and joined with single spaces. Node’s util.inspect plays no part in that, and the results are not what a console.log habit expects:
await executor.execute({ code: `async () => { console.log('rows', [1, 2, 3]) console.log({ a: 1 }) console.log(null, undefined, true) console.log(JSON.stringify({ a: 1 })) console.error(new Error('boom')) return 'done' }`,}){ result: 'done', logs: [ { level: 'log', text: 'rows 1,2,3' }, { level: 'log', text: '[object Object]' }, { level: 'log', text: 'null undefined true' }, { level: 'log', text: '{"a":1}' }, { level: 'error', text: 'Error: boom' } ]}A model that logs an object to explain itself gets [object Object] for the trouble. Ask for JSON.stringify in your prompt when you want readable logs. Each entry has level as its own field and no prefix inside text. Rendering is yours. A program the engine killed still comes back with the lines it logged before it died, because every line crossed as it was written. Runs that log without bound meet the console bound, and the last line you receive says so.
Surface a program may assume
Section titled “Surface a program may assume”The isolate starts with an empty global, and every name in it was installed on purpose: the JavaScript language, a standard library written to ECMA-429, the minimum common web API, a console, the timer family, the provider proxies you declared and the module map you passed. That whole surface is in the runtime pages, along with every place it deviates from the standard it targets. A bare read of a name nobody installed throws a ReferenceError, the way it does in any JavaScript. typeof reads 'undefined' instead, so a program that checks for a feature takes the branch it already has:
await executor.execute({ code: `async () => ({ WebSocket: typeof WebSocket, XMLHttpRequest: typeof XMLHttpRequest, EventSource: typeof EventSource, sendBeacon: typeof navigator.sendBeacon, process: typeof process, require: typeof require, WebAssembly: typeof WebAssembly, caches: typeof caches, })`,}){ result: { WebSocket: 'undefined', XMLHttpRequest: 'undefined', EventSource: 'undefined', sendBeacon: 'undefined', process: 'undefined', require: 'undefined', WebAssembly: 'undefined', caches: 'object' }, logs: []}But caches reads as an object, and rejects every call:
await executor.execute({ code: `async () => { try { await caches.open('v1') } catch (failure) { return failure.message } }`,}){ result: 'Cache storage is absent from this runtime because nothing survives an execution. Return what should persist in the result and take it back as an input on the next call.', logs: []}That sentence is one of the declared absences: a bare undefined there would send the model hunting for a bug that does not exist. Only a CommonJS module of the map gets a require, and the one it gets resolves against the map and nothing else.
Every isolate is disposed the moment the run finishes, and the variables, the caches and the state the program built go with it. Whatever should outlive a run comes back in the result and goes in again as an input to the next call. That rule is the persistence contract, and a retained execution is its one negotiated exception.
One executor, many programs
Section titled “One executor, many programs”new IsolateExecutor() spawns no child process. The first execute starts the pool, and the pool stays warm:
const executor = new IsolateExecutor({ timeout: 10_000, memoryLimit: 128 })
for (const program of programs) { const { result, error } = await executor.execute({ code: program })
console.log({ result, error })}
await executor.dispose()That loop shares one pool across every program in programs, and every execution still gets an isolate of its own. memoryLimit sits on the constructor alone, and no request field moves it. One program’s global does not survive into the next:
await executor.execute({ code: 'async () => { globalThis.left = "behind"; return 1 }' })// { result: 1, logs: [] }
await executor.execute({ code: 'async () => typeof globalThis.left' })// { result: 'undefined', logs: [] }globalThis.left reads undefined in the second run. The pool reuses processes and never isolates, and every run of pnpm verify runs a corpus of programs written to cross from one execution to the next, under the threat model.
dispose drains the pool and ends the children. Hosts that forget it keep child processes alive for as long as they run. After a dispose, execute rejects with an INTERNAL fault reading The pool is shutting down. and never starts a fresh pool. Keep one executor for the life of your application and dispose it when the application stops. The pool chapter says how a request finds a worker, what a worker is and when one is replaced, and the sandbox chapter says what happens between the generated entry module and the result.
This page runs one program and reads what comes back. Programs get at your data through your dispatch function and through nothing else, so give them tools next. After that, decide what a program may fetch on the network and which bounds apply while it runs.