Skip to content

Run it in your process

You construct the executor inside your own Node process. It spawns child processes beside you, the isolates run in those, and nothing between them crosses a socket.

Terminal window
npm install @supolka/codemode-executor isolated-vm

isolated-vm is a peer dependency, so your project owns the version. It is a native addon compiled against the V8 headers of whichever Node installs it, and its published tarball ships one prebuilt binary per platform and module ABI. So a Node major release means installing it again, and an upgrade inside one major does not. But where no prebuild matches, node-gyp builds the addon from source, and that build needs a supported Python and a C++ toolchain already on the machine.

@supolka/codemode-executor names the Node versions it runs on in engines, and npm only warns when the Node running the install falls outside a dependency’s range. Set engine-strict and it refuses.

Every worker exercises three engine behaviours as it starts, before it takes a call, because that peer range admits an isolated-vm predating all three. A worker that fails one rejects its first call with a WORKER_CRASHED fault reading Worker 1 failed to start., whose cause names what the installed build is missing:

The installed isolated-vm does not answer import() through the embedder callback, does not report an unhandled rejection to the embedder. Install an isolated-vm that does; this executor probes each behaviour and reads no version number.

Isolate and its startup snapshot says how a worker exercises each of the three.

import { IsolateExecutor } from '@supolka/codemode-executor'
const executor = new IsolateExecutor()
const outcome = await executor.execute({ code: 'async () => 6 * 7' })
console.log(outcome.result) // 42
await executor.dispose()

new IsolateExecutor() spawns no child process. The first execute starts a pool of them and leaves it warm, and that call pays a startup the ones after it do not. So build one executor per process. dispose shuts those children down. Leave it out and your process keeps running past your final statement, because they are still waiting for work. 6 * 7 runs in an isolate created for that call and disposed with its result, so the run after it starts on an empty global:

await executor.execute({ code: `async () => { globalThis.cache = 'kept?'; return 'first' }` })
// { result: 'first', logs: [] }
await executor.execute({ code: 'async () => typeof globalThis.cache' })
// { result: 'undefined', logs: [] }

globalThis.cache reads undefined in the second run, because that run gets its own isolate. A timer the first run leaves behind never fires either, since the isolate it belongs to is gone. What should outlive a run comes back in result and goes in again as an input of the next call. A retained execution is the one arrangement that keeps its isolate past the run.

code is an arrow expression, and the executor rewrites none of it on the way in. Even a trailing semicolon after that arrow is a syntax error. Models do not reliably emit a bare arrow either, so a fenced block handed straight over arrives at the isolate as source and fails there:

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

The fence is part of code, so the isolate evaluates it as source: two backticks make an empty string and the next one tags it, which calls "" as a function. Cleaning raw output into the entry form is the caller’s job, and one function does it:

import { normalizeCode } from '@supolka/cloudflare-codemode-executor'
await executor.execute({ code: normalizeCode(whateverTheModelWrote) })

normalizeCode ships in the adapter package, and a host that never touches the dialect installs that package for this one function. The API reference lists the rest of that package’s exports. It returns an arrow whatever shape it was given:

normalizeCode('```js\nasync () => 6 * 7\n```') // 'async () => 6 * 7'
normalizeCode('export default async () => 6 * 7') // 'async () => 6 * 7'
normalizeCode('return 6 * 7') // 'async () => {\nreturn 6 * 7\n}'
normalizeCode('6 * 7') // 'async () => {\nreturn (6 * 7)\n}'
normalizeCode('function answer() { return 6 * 7 }') // 'async () => {\nfunction answer() { return 6 * 7 }\nreturn answer();\n}'

Line five wraps the declaration and calls it, so the program still runs when the model writes a function and not an expression. Hosts that would rather not install that package ask for the arrow in the prompt and check the shape themselves. Entry form states what the executor accepts and what it turns down.

providers names the tools. dispatch runs them:

const orders = [
{ id: 'o1', user: 'ada', total: 42 },
{ id: 'o2', user: 'ada', total: 7 },
{ id: 'o3', user: 'grace', total: 100 },
]
const outcome = await executor.execute({
code: `async () => {
const rows = await store.listOrders('ada')
return rows.reduce((sum, order) => sum + order.total, 0)
}`,
providers: [{ name: 'store', tools: ['listOrders'] }],
dispatch: async (provider, tool, argsJson) => {
const [user] = JSON.parse(argsJson)
return JSON.stringify({ result: orders.filter((order) => order.user === user) })
},
})
console.log(outcome.result) // 49

store is a generated proxy inside the isolate. Every property read returns an async function, and calling one sends the provider name, the tool name and the arguments across the boundary as three strings. argsJson is the third of them. Arguments spread into an array by default, so [user] takes the first. Your dispatch runs in your own process with orders in scope, and the program never sees orders. tools records what the model was told about. But the proxy tests no name against it before it sends, so a program can call store.deleteOrders and that call arrives under the name it used:

await executor.execute({
code: `async () => await store.deleteOrders('ada')`,
providers: [{ name: 'store', tools: ['listOrders'] }],
dispatch: async (provider, tool) =>
JSON.stringify({ result: `${provider}.${tool} reached your dispatch` }),
})
// { result: 'store.deleteOrders reached your dispatch', logs: [] }

deleteOrders appears in no declaration on that request, and a provider nobody declared arrives the same way. Your dispatch decides. Key the lookup on the provider and the tool, turn down every other name, and let createEnvelopeDispatch from the protocol package build the response envelope. Undeclared names reach your dispatch shows how far past a declaration a program can go, and Dispatch worth copying has one written out.

With no outbound on the request, the worker child performs whatever HTTP request the program asks for, against your machine’s own network. null takes that network away for the run:

await executor.execute({
code: `async () => await (await fetch('https://example.com')).text()`,
outbound: null,
})
// { result: undefined, error: 'Outbound fetch is disabled for this execution. Anything this run needs from outside has to arrive through a tool call.', logs: [] }

fetch rejects with that text, the program never catches it, and the rejection becomes the run’s error. That sentence tells the model where the data has to come from instead. Pass a function in place of null and every request crosses your own code as plain data: the place an allow list and a credential belong. The network guide covers all three states, and the redirect a policy written the obvious way does not catch.

Set the bounds on the executor and every run inherits them:

const executor = new IsolateExecutor({
timeout: 5_000,
memoryLimit: 128,
pool: { maxWorkers: 4, maxQueue: 100 },
})

All four have defaults, so new IsolateExecutor() with no arguments still runs under every one of them, and the API reference prints the numbers. Only timeout moves on a request. V8 meters the isolate’s heap against memoryLimit, and a run that exhausts it comes back with the memory text in error, quoted under Error codes and texts. maxQueue bounds how many executions wait for a free worker, and past it execute rejects with a retryable QUEUE_OVERFLOW fault. Timeouts are measured out here and not inside the guest, so a program that never yields still ends on time:

await executor.execute({ code: 'async () => { while (true) {} }', timeout: 300 })
// { result: undefined, error: 'Execution timed out at its 300 ms bound. Do less work per run and return the part that is already finished.', logs: [] }

That text names 300 ms and not the 5,000 the executor was built with. The bounds guide covers every bound a run meets and how to choose the three that are yours, and the threat model says what each of them defends against.

Success and failure come back in one envelope:

await executor.execute({ code: `async () => { console.log('working'); return 6 * 7 }` })
// { result: 42, logs: [ { level: 'log', text: 'working' } ] }
await executor.execute({ code: 'async () => { throw new Error("nope") }' })
// { result: undefined, error: 'nope', logs: [] }

error is nope, the message the program throws, and execute resolves all the same. logs lists what the program writes to console. That message is written for the model to read, so hand it back and let the model try again. But a failure of the executor itself rejects the call, and that error has a code you branch on. The failures guide tells the two apart.

Run it as a service moves the pool out of your process, and your tools keep running where they are. Two capabilities this page never granted have guides of their own: modules a program may import, and a run that stays alive after it returns. Agent loop with the AI SDK puts this executor behind a model that writes the code.