Run it as a service
Here the isolates run somewhere else. Your host opens a WebSocket to a daemon, the program runs on that side, and every tool call it makes travels back to you while it runs. It is the only arrangement open to a host that cannot spawn child processes of its own, and the one a fleet grows into.
Ask the machine first
Section titled “Ask the machine first”Before anything is deployed, ask whether this machine can run sandboxed code. Build the daemon and run its check from the repository:
pnpm nx run daemon:buildnode apps/daemon/dist/bin.js checkcheck puts one real job through the whole path, isolate included, and names whatever it could not find. Machines that answer sign off like this:
└ This machine can run sandboxed code.Scripts read the exit code instead: 0 when the sandbox answered, and 5 when the machine cannot run one at all. That 5 belongs to check and to no other command, so it is the number to alert on. Otherwise containers crash-looping for want of isolates look exactly like containers crash-looping for any other reason. Run check --json and the same verdict arrives as one object, carrying ok beside the version, the runtime, the answer it got and how long the job took.
Start it
Section titled “Start it”HOST=127.0.0.1 PORT=8080 node apps/daemon/dist/bin.jsThat HOST=127.0.0.1 is not decoration. By itself the daemon runs whatever code reaches it and authenticates nobody, and its own default binds every interface. Any address reachable past the callers you meant is arbitrary code execution offered to whoever finds it first. A laptop wants the loopback address. A cluster wants a NetworkPolicy, and the Prometheus scrape sitting on the very port the callers use is a second reason to write one. The operator guide says what a cluster leaves open until one is written.
Started this way the process stays in the foreground, and it writes one JSON object per line. Two of those lines say it is up, because two listeners open, not one. Callers reach the first. An administrative listener holds the second, on the loopback address, answering the process status, the settings in force and the maintenance switch. Each line names the address its listener took. Two listeners and their addresses covers both, along with the image a deployment runs in place of this command. The caller port answers a few paths of its own, and hitting them is the quickest way to see it serving:
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-executorThat package holds the remote executor. It is JavaScript over capnweb and imports nothing from Node, so the same code runs inside a Worker.
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()That execute is the same contract the in-process executor serves, field for field, so a host can hold either one behind a single interface and never learn which it got. Run the same program on either one and the same envelope comes back:
await executor.execute({ code: `async () => { console.log('over the wire'); return 1 }` })// { result: 1, logs: [ { level: 'log', text: 'over the wire' } ] }
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: [] }Connections that go wrong say so. Connecting to the wrong address rejects with a CONNECTION_REJECTED fault reading Executor handshake failed for ws://127.0.0.1:9, and a disposed executor does not come back: the next call rejects with CONNECTION_BROKEN reading RemoteExecutor is closed.
Your tools stay yours
Section titled “Your tools stay yours”const outcome = 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,})Your dispatch never leaves your process. Your program runs on the daemon, each call it makes travels back over the socket it arrived on, your function answers, and the answer travels forward. The daemon holds no credential of yours and never sees your database. Outbound functions work the same way, so a policy written in your host governs a program running on someone else’s machine:
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, }),})// { result: 'served in the host process for https://api.internal/ping', logs: [] }Your function reads request.url as plain data and answers the response the guest goes on to read. For that request the daemon opens no connection of its own.
Keep the connection
Section titled “Keep the connection”One connection lives 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. Either way the pool behind the daemon is shared.
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() }}A long-lived connection is not a fragile one. The session retires itself every rotateAfterExecutions executions, and a call that finds no live session opens another. That reconnect covers the calls that have not started yet. Executions already running when the socket breaks meet a CONNECTION_BROKEN fault carrying retryable: true, and whether to run one again is your host’s call. Their tool calls are not safe to make twice. examples/nestjs in the repository 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”Callers set their own bounds, and a server is entitled to overrule some of them. Start one that does:
HOST=127.0.0.1 PORT=8080 MAX_TIMEOUT_MS=1000 RESPONSE_LIMIT_BYTES=4096 node apps/daemon/dist/bin.jsMAX_TIMEOUT_MS caps what any request may ask for. Runs against that daemon report the bound that actually applied, not the one they asked for, so the model reads a true number:
await executor.execute({ code: 'async () => { while (true) {} }', timeout: 60_000 })// { result: undefined, error: 'Execution timed out at its 1000 ms bound. Do less work per run and return the part that is already finished.', logs: [] }RESPONSE_LIMIT_BYTES caps what one execution may return, but it lands on the opposite side from the timeout. Rejection replaces the answer, so your host is the only party that hears about it and the model never learns that its answer was too large:
await executor.execute({ code: `async () => 'x'.repeat(20_000)` })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.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 serves the request as it always did.
Both quickstarts end with an executor serving. The guides take one host decision per page, from what the model may call to how a failure comes back. The operator guide 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 served. Hosts already speaking the @cloudflare/codemode dialect go to the migration instead.