Run it as a service
The isolates move out of your process here, and every call to them crosses a socket. Your host opens a WebSocket to a daemon, the program runs on the daemon’s side of it, and every tool call the program makes travels back to you while it runs. Two things force that split: a host that cannot spawn a child process of its own, and a fleet that wants one pool behind many callers. The daemon may sit on another machine, and every command below runs it on this one.
Ask the machine first
Section titled “Ask the machine first”Ask whether a machine can run sandboxed code before you deploy anything to it. The daemon and its CLI ship in one container image, and docker run hands check to the binary inside it:
docker run --rm ghcr.io/maksimyaromin/codemode-executor:latest checkcheck puts one real job through the whole path, isolate included, and names whatever it could not find. On a machine that can, the report ends here:
└ This machine can run sandboxed code.check --json prints the same verdict as one object: ok, version and runtime always, then answer and duration where the sandbox ran, and failure where it did not. Scripts read the exit code too, and 5 is the one to alert on: check alone sets it, and containers crash-looping for want of isolates otherwise look exactly like containers crash-looping for any other reason. codemode-executor --help prints all four:
| Exit code | What it means |
|---|---|
| 0 | It did what you asked. |
| 1 | A setting was wrong, or the daemon it was told to reach did not answer. |
| 2 | The address it was told to listen on was already taken. |
| 5 | The check ran and the sandbox did not answer. |
Start it
Section titled “Start it”docker run --rm -p 127.0.0.1:8080:8080 ghcr.io/maksimyaromin/codemode-executor:latestThe address in front of the published port, 127.0.0.1: after -p, decides who can connect to it. Drop that prefix and Docker publishes on every interface of the host. This daemon runs whatever code arrives and checks no credential, so an address your callers do not need is arbitrary code execution offered to whoever finds it first. But inside the container, binding every interface is right, because HOST=127.0.0.1 there would bind the container’s own loopback and leave the daemon unreachable from outside it.
On a laptop, publish to the loopback address as above. In a cluster, write a NetworkPolicy. Callers and the Prometheus scrape share a port, and that is a second reason to write a policy. Deploying the daemon says what a cluster leaves open until one exists.
Started this way the process stays in the foreground and writes one JSON object per line. Two listeners open, and each announces the address it took:
| Listener | Address | Purpose |
|---|---|---|
| Callers | HOST and PORT, every interface by default |
Executions over the WebSocket, plus /healthz, /readyz and /metrics |
| Admin | ADMIN_HOST and ADMIN_PORT, the loopback address by default |
The process status, the settings in force and the maintenance switch |
Neither listener checks a credential, so neither belongs on an address a stranger can reach. Two listeners and their addresses covers the settings that move either one and the routes the admin listener exposes. Three paths on the caller port are the quickest way to see the daemon up:
curl -sS http://127.0.0.1:8080/healthz # okcurl -sS http://127.0.0.1:8080/readyz # readycurl -sS http://127.0.0.1:8080/metrics # a Prometheus scrapeConnect
Section titled “Connect”npm install @supolka/cloudflare-codemode-executorconnect comes from that package, and so does the RemoteExecutor it returns. The package is JavaScript over capnweb and imports no Node builtin, so the same code runs inside a Worker. It pulls in no native addon either, so a host that only connects builds nothing on install.
import { connect } from '@supolka/cloudflare-codemode-executor'
const executor = await connect('ws://127.0.0.1:8080')
const outcome = await executor.execute({ code: 'async () => 6 * 7' })
console.log(outcome.result) // 42
await executor.dispose()connect is asynchronous because the version handshake runs inside it. The RemoteExecutor it returns implements the same Executor interface IsolateExecutor does, so a host can accept either. Run the same program on both and the values read the same:
await executor.execute({ code: `async () => { console.log('over the wire'); return 1 }` })// { error: undefined, logs: [ { level: 'log', text: 'over the wire' } ], result: 1 }
await executor.execute({ code: 'async () => { while (true) {} }', timeout: 300 })// { error: 'Execution timed out at its 300 ms bound. Do less work per run and return the part that is already finished.', logs: [] }error: undefined on the first line comes from the wire envelope, which always carries error; IsolateExecutor omits that key on a successful run. result reverses the pair: the wire omits it whenever a run produced none, which covers a failure, a success returning undefined and every retained run, and IsolateExecutor always sets it. So read outcome.result and outcome.error, and never test whether a key exists. connect itself rejects with a CONNECTION_REJECTED fault reading Executor handshake failed for ws://127.0.0.1:9 when nothing is listening at the address. A disposed executor does not come back: the next execute rejects with CONNECTION_BROKEN reading RemoteExecutor is closed.
Your tools stay yours
Section titled “Your tools stay yours”await executor.execute({ code: 'async () => (await store.listOrders("ada")).length', providers: [{ name: 'store', tools: ['listOrders'] }], dispatch: async (provider, tool, argsJson) => { const [user] = JSON.parse(argsJson)
return JSON.stringify({ result: await database.orders(user) }) }, outbound: null,})dispatch crosses as a live RPC stub, and the function itself never leaves your process. The program runs on the daemon, each call it makes travels back over the socket it arrived on, your function runs, and the value it returns travels forward. So database stays where it is, and no credential of yours ever leaves your process. Outbound functions cross the same way, and a policy written in your host then governs a program running on the daemon:
await executor.execute({ code: `async () => await (await fetch('https://api.internal/ping')).text()`, outbound: async (request) => ({ status: 200, statusText: 'OK', headers: [['content-type', 'text/plain']], body: new TextEncoder().encode(`served in the host process for ${request.url}`).buffer, }),})// { error: undefined, logs: [], result: 'served in the host process for https://api.internal/ping' }request.url arrives as plain data, and the object your function returns becomes the response the guest reads. With a function in outbound, the run hands it every request and the daemon performs none itself. The network guide covers what a function like that checks before it makes the request.
Keep the connection
Section titled “Keep the connection”Open one connection for the life of your application. A connection per request also works, but it pays a TCP connect, a WebSocket upgrade and a version handshake before every execution. Both ways share the one pool behind the daemon.
import { connect, type RemoteExecutor } from '@supolka/cloudflare-codemode-executor'
class Executions { private executor: RemoteExecutor | undefined
async start(): Promise<void> { this.executor = await connect(process.env.EXECUTOR_URL ?? 'ws://127.0.0.1:8080') }
async stop(): Promise<void> { await this.executor?.dispose() }}start opens the socket and stop closes it, and nothing between them touches the connection. The session retires itself every rotateAfterExecutions executions, and a call that finds no live session opens another, so a reconnect covers every call still waiting to start. Executions already running when the socket breaks reject with a CONNECTION_BROKEN fault marked retryable: true. Whether to run one again is your host’s decision, because its tool calls already ran against your data once. examples/nestjs is this class grown into a working NestJS application, with an end-to-end suite that forks a real daemon to run against.
Settings the daemon overrules
Section titled “Settings the daemon overrules”Some settings on the daemon overrule the caller. Start a daemon that uses two of them:
docker run --rm -p 127.0.0.1:8080:8080 -e MAX_TIMEOUT_MS=1000 -e RESPONSE_LIMIT_BYTES=4096 \ ghcr.io/maksimyaromin/codemode-executor:latestMAX_TIMEOUT_MS caps what a request may ask for:
await executor.execute({ code: 'async () => { while (true) {} }', timeout: 60_000 })// { error: 'Execution timed out at its 1000 ms bound. Do less work per run and return the part that is already finished.', logs: [] }1000 ms is the cap in that docker run, and the failure text names it and not the 60,000 the request asked for. So the model reads a number it can act on. RESPONSE_LIMIT_BYTES caps what one execution may return. execute rejects, the result goes with it, and the model never learns that what it returned was too large:
import { ExecutorError } from '@supolka/codemode-executor-protocol'
try { await executor.execute({ code: `async () => 'x'.repeat(20_000)` })} catch (failure) { if (failure instanceof ExecutorError) { console.log(`${failure.name} | ${failure.code} | retryable ${String(failure.retryable)}`) console.log(failure.message) }}ExecutorError | RESPONSE_TOO_LARGE | retryable falseThe execution response of 20,031 bytes exceeds the response limit of 4,096 bytes. Return less data, or raise the response limit on the server.instanceof ExecutorError works on a fault that crossed the socket, because the remote executor builds a new ExecutorError from the code on the wire error before it throws. Error codes and texts lists every code a host branches on. OUTBOUND_MODE=null denies native outbound however a caller asked for it. An execution that named no outbound of its own meets the text saying outbound is disabled. One that passed a function keeps it, and that function still receives every request.
Both quickstarts end with an executor running programs. The guides take one host decision per page, from what the model may call to how a failure comes back, starting with Executing code. Deploying the daemon covers the other half, what a deployment of this daemon decides: how much memory it needs, what to scale on, how a stop signal is handled. Hosts already speaking the @cloudflare/codemode dialect go to the migration instead, and Agent loop with the AI SDK puts an executor, a model and your tools in one loop.