Run it in your process
Your own Node service owns the executor here. It spawns child processes beside itself, the isolates run in those, and no network sits between them.
Install
Section titled “Install”npm install @supolka/codemode-executor isolated-vmThis package names a Node floor in its engines field, and npm treats that field as advisory for a dependency, so an install underneath it warns and still succeeds. But the floor is real. Set engine-strict and npm refuses the install instead. isolated-vm is a native addon compiled against the V8 headers of whichever Node installs it. Compiling against those headers pairs the addon version and the runtime version into one choice, not two, and decision 0002 is where that choice was made.
Patching isolated-vm
Section titled “Patching isolated-vm”Engine behaviours the sandbox depends on come from a patch the repository keeps under patches/, written against the pinned release. Every worker performs each of those behaviours as it starts, before it takes a call. No version number is read. Getting that patch onto your own copy is your package manager’s job, and so is the compile that has to follow it: no prebuilt binary carries a change made after it was published. With pnpm that is two entries in pnpm-workspace.yaml: patchedDependencies naming the patch file, and allowBuilds naming isolated-vm, because pnpm runs no dependency’s build script until that list allows it. Decision 0006 says why the fix went upstream first and what retires the patch.
Skip it and the executor fails outright instead of degrading. A worker started against a stock build rejects the first call with a WORKER_CRASHED fault reading Worker 1 failed to start., and the chain of causes behind it names the behaviours that build turned out to be missing:
The installed isolated-vm does not answer import() through the embedder callback, does not report an unhandled rejection to the embedder. This executor needs the changes in patches/[email protected], which pnpm applies in this repository and a consumer applies through its own package manager.First execution
Section titled “First execution”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()Constructing the executor spawns no child process. Its first execute starts a pool of them and leaves it warm, and the first call pays a startup the ones after it do not. So a host builds one executor for its whole lifetime, not one per request. dispose is on the last line for the same reason: leave it out and your process keeps running past your final statement, because its children are still waiting for work. The pool persists; the isolate does not. 6 * 7 is evaluated in one created for that call and disposed as the result comes back, so nothing a program leaves behind is waiting for the program after it.
Model output is not the entry form
Section titled “Model output is not the entry form”What you pass as code is an arrow expression, and the executor rewrites none of it on the way in. A model does not reliably emit a bare arrow, so a fenced block handed straight over reaches the isolate as source and fails on it:
await executor.execute({ code: '```js\nasync () => 6 * 7\n```' })// { result: undefined, error: '"" is not a function', logs: [] }Passing the fence as part of the code string leaves the run with an error and no result. Cleaning raw output into the entry form belongs to the caller, and the dialect adapter exports the function that does it:
import { normalizeCode } from '@supolka/cloudflare-codemode-executor'
await executor.execute({ code: normalizeCode(whateverTheModelWrote) })normalizeCode takes a fenced block, a leading return, an export default, a bare expression or a named function and answers the arrow. Hosts that would rather not carry that package ask for the arrow in the prompt and check the shape themselves. The entry form states what the executor accepts and what it turns down.
Give it something to do
Section titled “Give it something to do”Programs that cannot reach your data are not worth running. You declare what the model is told about, and you serve what it calls:
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) // 49Inside the isolate store is a generated proxy: every property answers a function, and calling one sends the provider name, the tool name and the arguments across the boundary as three strings. Your dispatch runs out here, with your data in scope and none of it visible to the program. Your declaration is only what the model is told about. It is not a boundary. A program is free to call a name the list never carried, and that call arrives at your dispatch under the name it used. Your function decides, so key the lookup on the provider and the tool and turn down anything else. The tools guide carries a dispatch worth copying, and the protocol package builds the response envelope for you.
Close the network
Section titled “Close the network”Left alone the executor performs whatever HTTP request the program asks for, against whatever its own network can reach. That is deliberate. But it is rarely what a host wants:
await executor.execute({ code, outbound: null })null means the network does not exist for that execution, and fetch rejects with a text that tells the model where the data has to come from instead:
Outbound fetch is disabled for this execution. Anything this run needs from outside has to arrive through a tool call.
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
Section titled “Set the bounds”Every bound has a default. Set them on the executor and every run it serves inherits those numbers:
const executor = new IsolateExecutor({ timeout: 5_000, memoryLimit: 128, pool: { maxWorkers: 4, maxQueue: 100 },})Timeouts are measured out here, not inside the guest, so a program that never yields still settles 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, not the 5,000 ms the executor was built with: whichever timeout a request names wins. Memory has a bound of its own, and the engine enforces it on the isolate’s heap. Past the queue bound a caller meets a typed, retryable fault and not a backlog nobody is draining. Neither one travels on a request, so the executor is the only place to set them. The bounds guide is how to choose the numbers, and the threat model says what each of them holds back.
Reading the outcome
Section titled “Reading the outcome”A run that worked and a run that threw come back in the same 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: [] }A program that failed is not an exception on your side. It comes back as error, written for the model to read and act on, and the run still counts as served: the model wrote something that did not work, and it can be handed that sentence and try again. But a failure of the executor itself does throw, and it carries a code you branch on. The failures guide tells the two apart without guessing.
Run it as a service and the pool moves out of your process, while your tools keep answering from where they are. Two capabilities this page never granted live in the guides: modules to import, and a run that keeps serving after it returns.