Skip to content

Executing code

One call runs one program, and that call carries every input that program will ever have:

type ExecuteRequest = {
code: string
providers?: Array<ProviderDescriptor>
dispatch?: ToolDispatchFn
modules?: ModuleMap
outbound?: OutboundFetchFn | null
timeout?: number | null
countHostWaits?: boolean
retainHandles?: boolean
}

code is the only field you have to fill. Every other one either hands the program a capability or takes one away. providers and dispatch are the tools it may call, modules names the modules it may import, outbound is the network, timeout and countHostWaits are the clock, and retainHandles asks the executor to keep the isolate alive after the run. Most of those can be set once, when you construct the executor. Whichever value the request names wins, 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 rejects
await executor.execute({ code, outbound: policy }) // fetch reaches your policy

Every program is an 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 answers the array of names it built. It usually returns a promise. But it does not have to: the executor invokes the expression and awaits whatever comes back, so () => 6 * 7 and () => Promise.resolve(42) both answer 42.

The form is a contract because the executor wraps it. 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. Named function expressions fit, and async function main() { return 42 } answers 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 the statement list again, reported this time on a word strict mode reserves, 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: [] }

Every one of those texts is V8 speaking. Each arrives with a position appended. 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. Fenced blocks straight out of a model reach the isolate as source and die on the backticks:

await executor.execute({ code: '```js\nasync () => 6 * 7\n```' })
// { result: undefined, error: '"" is not a function', logs: [] }

Turning raw model output into the entry form is yours to do, and the dialect adapter exports the function that does it. normalizeCode from @supolka/cloudflare-codemode-executor covers the shapes a model actually emits:

What the model emitted What normalizeCode answers
```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 answers 42 once it runs. 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.

Every execution that settles answers one object:

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 there holding undefined, so the branch has to test the value and not whether the key exists. But a failed program is not a failed call. Nothing about your host went wrong. 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. Whatever failed is written into error for the model to read. 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 carries the declared sentence of the failure grammar instead, and those sentences are contract. Failures of the executor itself never arrive here at all: they are thrown, and the failures guide tells the two apart. logs is always there, and it is empty when the program wrote no lines. executionId and handles appear only when a run retained its handles.

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,
})`,
})

The host holds this afterwards:

{
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 things change on the way out. undefined and the function lose their keys altogether. d is now its ISO string. Three more arrive empty: the Map, the Set and the RegExp keep none of their contents in enumerable properties. And the Float32Array comes back as the four bytes underneath it, because a typed array that is not a Uint8Array decays to plain bytes.

Binary is the one non-JSON shape that survives whole. 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 answers { 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. Size bounds what you send back too: every message a run pushes across the boundary has a ceiling on it, the result included. Past that ceiling the run is answered with a sentence naming the size and the bound, and the worker holding it stays up. Read the bounds guide for the number and the reason.

console.log, console.warn and console.error are captured and travel back with the result. Every other console method a program might reach for exists and writes no line, console.debug, console.info and console.table among them. The protocol names the whole set the sandbox carries.

Every line is built by stringifying each argument with String() and joining the results 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' }
]
}

Models that log an object to explain themselves get [object Object] for the trouble, so a host that wants readable logs asks for JSON.stringify in its prompt. The wire carries the level as a field and adds no prefix; how you render it is your business. Capture happens on the host as each line is written. So a program the engine killed still comes back with the lines it logged before it died. Runs that log without bound meet the console bound, and the last line you receive says so.

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 answers differently from the standard it targets. Absent names stay absent, and most read as undefined, 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 is the odd one out. It reads as an object and rejects its first touch with a sentence explaining why. The declared absences exist for that case: a bare undefined there would send the model hunting for a bug that does not exist.

Nothing survives the run. The isolate is disposed the moment the result settles, 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.

Constructing an executor spawns no process. The first execution 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. Every execution still gets an isolate of its own. The pool reuses processes and never isolates, so what one program leaves behind cannot reach the next one, and a corpus of programs written to try it runs on every gate 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, a call to execute answers an INTERNAL fault reading The pool is shutting down. and never starts a fresh pool. Hold 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. Without a declaration a program reaches nothing of yours, so give it tools. After that, decide what it may reach on the network and what holds it while it runs.