Skip to content

The executor protocol

Two readers hold this contract to its word, and they read it at different moments. Host implementers read it while writing the code that calls the executor. The model whose program is running reads part of it during the run: a declared failure here is a sentence composed for whatever writes the next attempt. So the texts below are pinned, not paraphrased.

Half of the contract is machine-readable, and it lives in @supolka/codemode-executor-protocol, a package with no dependencies that both sides of the wire implement. Two executors serve that same contract: the in-process one of @supolka/codemode-executor and the remote one of @supolka/cloudflare-codemode-executor. Consumers on any JavaScript runtime can implement it from the protocol package alone. Every bound stated below is a safety claim, the timeout and the memory limit above all, and the threat model says which boundary holds each one.

In-language, the contract is the Executor interface: execute(request) answers an ExecuteResult, and dispose() releases whatever the executor holds. Every ExecuteRequest states one execution in full: the code, the provider descriptors, the dispatch function, the module map, the timeout and the outbound setting. Defined request fields win over any construction-time default of the executor serving them, and an undefined field means the same as an absent one.

On the wire the contract is the ExecutorApi RPC surface, spoken over a WebSocket carrying a capnweb session:

  • protocolVersion() answers an integer, and the connecting side compares it against its own for exact equality before anything else crosses the connection. PROTOCOL_VERSION in the protocol package is the value a build speaks, and the reference records what it holds today.
  • execute(code, providers, options, dispatch, outboundMode, outbound) runs one execution. Dispatch and outbound functions cross as live RPC stubs, so the provider implementations never leave the caller’s process. Requests that carry no tools send null for the dispatch, and the sandbox answers every tool call with the not-found envelope.
  • invokeHandle(executionId, handleName, request) calls a handle of a retained execution with a serialized request and answers the serialized response, and closeHandles(executionId) releases the execution. Execution handles states the lifecycle. Once the handles are gone, both reject with the HANDLES_CLOSED fault.

Two values change shape on the wire. execute returns an ExecuteResultMessage, where the run’s own value is resultJson, the JSON text the isolate produced, and the receiving side rebuilds it with parseForDispatch. The module map crosses as [name, source] pairs. capnweb deletes every own key of a plain object that names an Object.prototype member, so a result with a constructor key would arrive without it, and a module named toString would go missing from the map. The pool chapter quotes the deletion.

Code arrives as an arrow expression, optionally preceded by static import declarations of module-map entries. The executor invokes the arrow and the value it settles to is the result of the run. Nothing rewrites the code on the way in, so a program that does not fit the contract reports its own syntax error inside the result envelope. Cleaning raw model output into this form is the caller’s concern, and the codemode adapter does exactly that for its dialect.

Imports have to be lifted. Import declarations are legal only at module scope, while the arrow becomes a function body. Lifting works a line at a time. Leading lines that begin an import are taken whole, trailing code included, so import x from 'a'; const size = 1 puts both at module scope and the body still closes over size. Callers pay one rule for that: the arrow itself starts on a line of its own. Arrows written behind an import on the same line are lifted with the import, and the body is left empty. Then the run reports a syntax error pointing into the generated entry, not into anything the model wrote.

Every provider descriptor carries a name and the list of its tool names. Three members are optional: prelude, source that runs after every proxy is declared; proxySource, source that replaces the generated proxy for that provider; and positionalArgs, the choice of how the generated proxy passes call arguments. Provider names must be valid JavaScript identifiers. Texts for a reserved, invalid, reserved-word or duplicate name are part of the contract, and each is composed by its own error class in the protocol package.

But the tool list itself is never validated. The generated proxy answers every property through its get trap, so any tool name reaches the dispatch. __dispatch itself takes the provider name as an argument, so a name no descriptor carried reaches it too. Descriptors say what the caller told its model about. Only the caller’s own dispatch function knows what it is willing to serve, so that function is where the decision belongs.

Generated and provider-supplied source runs in the scope of the entry module, and the names that scope provides are contract:

  • __dispatch(provider, tool, argsJson) sends a tool call to the host and answers a promise for the envelope JSON.
  • __stringifyForDispatch and __parseForDispatch are the dispatch codec.
  • __BINARY_TAG, __encodeBinaryValue, __decodeBinaryValue, __encodeBase64 and __decodeBase64 are the codec’s parts, present because the codec is assembled from them in that scope.

Every one of those names is reserved as a provider name, together with the names the generated source itself depends on and the host bindings, timers and codec globals the sandbox installs. packages/protocol/src/provider-name.ts holds the whole set, grouped by the reason each name is in it. No declaration can shadow any of them.

Reserved names cover what the generated source would break on, not the whole standard library, and the gap is deliberate. Names the runtime installs that the reserved set does not carry, fetch and Response among them, are accepted as provider names and shadow those globals inside the entry module for the whole run. Nothing outside the entry module is affected and no capability is gained, but a program written expecting the global finds the provider instead. A provider list assembled from model output or from user input can carry any of them, and its contents are the caller’s decision.

Tool calls cross every boundary in one grammar. Arguments travel as one JSON string: an array is an argument list, the empty string is an empty argument list, and any other payload is a single argument. Answers travel as one JSON string carrying an envelope: {"result": value} for a settled answer and {"error": text} for a failure. The sandbox proxy rethrows the error text behind the boundary.

JSON cannot carry binary data, so a Uint8Array, an ArrayBuffer or a typed array crosses as a base64 envelope tagged with the string __executor_binary_v1__. Typed arrays other than Uint8Array decay to plain bytes on the way through.

Two dispatch answers are pinned texts. Calls naming a tool nobody serves answer the envelope error Tool "<tool>" not found, and a call that lands after the execution settled answers Execution has already completed.. Any answer that is not an envelope object is a protocol fault, and the generated proxy rethrows it as The dispatch answered outside the result envelope..

The whole console namespace of the Console Standard is there. log, warn and error are captured and travel back with the result; every other method runs and writes nothing, so a library that calls console.time or console.debug works and stays quiet. The sandbox leaves out three Node additions that answer with an object. Node documents one of them, the Console constructor, and ships console.context and console.createTask with no documentation at all. Each would need a shape this runtime has no way to give it.

Captured lines cross as a level and a text, formatted by joining the stringified arguments with single spaces. Readers decide what to render from the level, and the codemode adapter, for one, renders its dialect’s prefixes.

One execution’s captured text is bounded, and the bound is the one below. Each line is charged what it costs on the wire, its own shape included, so a run writing empty lines reaches the bound like any other. The line that meets the bound is dropped whole. A final warn names the bound and says the rest was dropped, and the run sends no line after it.

Anything a run sends outward is host memory the executor holds and a message it has to deliver, and each transport in the path caps one message. Past that cap the message is not delivered and the transport under the run fails instead. So each of these is bounded where it is built, and a run that reaches a bound is answered, not sent:

  • The answer of the run is the result or the thrown message, whichever it ended with.
  • A tool call is counted over the provider name, the tool name and the arguments together.
  • An outbound request is counted over its URL, its headers and its body.
  • The answer of a handle invocation is counted over its status text, its headers and its body.

Reaching a bound answers PayloadTooLargeError, whose text names the part that was too large, its size and the bound it passed. PAYLOAD_LIMIT in the protocol package is that bound, sized together with the console bound below: a run at every bound at once still leaves the largest message far under the smallest ceiling in the path.

But the captured console is bounded separately and answers differently. CONSOLE_CAPTURE_LIMIT counts the lines of the whole run together, and a run that fills it does not fail. Capture stops, one warn line carrying CONSOLE_CAPTURE_MESSAGE is appended in place of the rest, and the run continues to its own result. Logging is a side channel, so a program that fills it loses its logging, not its answer.

Sandbox fetch is served through a tri-state setting. Absent means the executor performs the request natively. A function means every request is delivered to that function as a serialized exchange, request and response as plain data. Null means the network does not exist for that execution, and a fetch call rejects with the outbound text of the failure grammar.

On the wire that state travels as the mode string native, stub or disabled, naming the caller’s intent instead of leaving it to be read off whether an argument arrived. The server switches on the name. stub duplicates the caller’s function and routes every request back through it. disabled leaves that execution with no network. native hands the choice to the server’s own configuration. On a server whose operator vetoed it, a request that asked the executor to fetch for itself gets no network at all, and the operator guide covers that veto. Modes the server does not recognize are refused with PROTOCOL_MISMATCH; a tri-state read off the arguments has no unrecognized case to refuse.

The timeout bounds one execution’s run. Requests without one run under the executor’s configured default, and below that sits DEFAULT_TIMEOUT in the protocol package. A null timeout lifts the bound to the largest value the serving side supports, never above MAX_SUPPORTED_TIMEOUT: the widest delay a 32-bit timer accepts. Past that delay a timer fires almost at once, so a lifted bound would become no bound at all. Runs that outlive their bound report the timeout text of the failure grammar below. Its first words are the pinned sentence Execution timed out.

The timeout is measured on the run clock, and a wait the caller serves does not count toward it. While the only running work is a tool call or a request to the caller’s outbound function, the clock stops; it runs again when guest code runs. Tools that take ten minutes to answer, an approval flow above all, do not end a run whose own work stayed inside the bound.

But one question separates a host wait from the rest: whose work the wait is. Several kinds of work fall on the guest’s side of that line. Guest compute counts even while a host call is in flight. Requests the executor performs natively count, because they go to a URL the guest chose and nothing but the run bounds them. So does the cryptography the host performs behind crypto.subtle: the run’s own work carried out elsewhere. A guest setTimeout falls on both sides. Its delay spends the bound while no host call is open, and spends none of it while a host call is in flight. Whatever the callback does when it fires is guest compute, and it counts either way.

Setting countHostWaits to true, on the executor’s construction options, the request or the wire call options, restores the plain wall clock for that execution. The codemode adapter sets it on every request it issues, because the dialect it serves measures the wall. But a run whose clock is stopped still occupies its worker, and the caller bounds tool and outbound latency. Executors out of capacity turn new work away with the queue fault and never end a run that is waiting.

Two readers meet the executor’s failures, and the primary one is the model whose code just ran. The model meets a declared failure: the executor turning something down on purpose, a bound enforced or a surface declared absent. The calling application meets a fault: the infrastructure breaking under the execution.

A declared failure is text the model reads in-band, inside the result envelope’s error or as a rejection its code can catch. Every text follows one grammar: the first sentence names what failed and why, and the sentence after it says what remains possible. Each failure is declared in the protocol package as an error class that composes its own text in its constructor, and each text is pinned by a direct test. The boundary that reports one picks the carrier. Host code throws the instance. The sandbox runtime raises the message inside the error its own surface throws: a DOMException for a declared absence, and a TypeError for a fetch the outbound setting refused. Generated source and the result envelope carry the message as text. These are the declared failures:

  • A run that outlives its bound reports ExecutionTimeoutError: Execution timed out at its <timeout> ms bound. Do less work per run and return the part that is already finished. The text opens with the pinned dialect sentence Execution timed out, and ExecutionTimeoutError.matches answers whether a text is that whole first sentence. Adapters recognize the timeout that way.
  • A run that reaches its memory limit reports MemoryLimitError: Execution ran out of memory at its <memoryLimit> MB limit. Work through the data in smaller pieces and keep only what you return.
  • fetch with outbound disabled rejects with OutboundDisabledError: Outbound fetch is disabled for this execution. Anything this run needs from outside has to arrive through a tool call.
  • An import the module map cannot answer reports ModuleNotFoundError: Module "<name>" is not in the module map. Import one of the names it declares: <declared>. Where it arrives depends on the form of the import. A dynamic import() rejects with it, so the program can catch it. But a static import is resolved before the entry ever runs, so the run never starts and the text arrives in the envelope. ModuleKeyCollisionError and UnsupportedModuleKindError cover a map whose keys collide after normalization and an entry with no supported kind.
  • A provider list that cannot become the entry’s declarations is reported inside the envelope through the provider name classes, one per rule the provider section states.
  • The declared absences below and the persistence contract answer with the texts their own sections state.

A fault throws. Every thrown failure carries a code property from the taxonomy in the protocol package, and a code exists only while a consumer can actually meet it on a thrown error:

Code Where a consumer meets it
QUEUE_OVERFLOW execute rejects because the pool’s queue is at its bound; retryable.
WORKER_CRASHED execute rejects because the child under the execution died or never started; retryable.
PROTOCOL_MISMATCH The versions disagree at the handshake, or the wire carries an unknown outbound mode.
CONNECTION_REJECTED connect fails on the socket or the handshake.
CONNECTION_BROKEN The transport under a call died, whether WebSocket, IPC or a disposed executor.
RESPONSE_TOO_LARGE The server measured the result envelope, or a handle invocation’s answer, above its response limit.
HANDLES_CLOSED invokeHandle or closeHandles named an execution whose handles are gone: closed, expired at the idle bound, ended by a run bound, or never granted.
INTERNAL An invariant of the executor itself broke.

A plain Error crosses the wire, carrying the message and any own properties the thrown value had. Three things do not cross: the class, the name and the stack. Of the three, the stack is the one worth knowing about, because the caller does read one, but it was manufactured where the error arrived and it names the transport, not the line that threw. That leaves the code property as the only way back to a typed error, so a consumer branches on the code, and rehydrateExecutorError rebuilds the instance from it. Errors carrying no code from the taxonomy pass through untouched, so a throw from user code or a rejection from a provider keeps its own shape.

Three fault texts are declared failures with classes of their own: QueueOverflowError at the queue bound, ResponseTooLargeError at the response limit, and HandlesClosedError reading Execution "<executionId>" holds no handles: they were closed, expired at the idle bound, or never granted. Execute again with retainHandles to get a fresh executionId. Those texts follow the grammar of the declared failures, while the remaining texts state what broke.

The grammar binds only the failures this executor declares. Pinned dispatch answers keep their own shape, so do the engine’s error texts such as a syntax error of the executed code, and so does whatever user code throws. The envelope reports a throw from user code as it reads.

Surfaces this runtime does not carry on purpose still exist under their own names. Each rejects the first touch instead of reading as a bare undefined, so the code that reached for one learns why and what remains possible. Each rejection is a DOMException named NotSupportedError. Three error classes compose the texts:

  • CompressionStream and DecompressionStream exist and reject construction with CompressionAbsentError’s text: <name> is absent from this runtime by design. The host applies content coding on the wire, so work with uncompressed bytes.
  • ArrayBuffer.prototype.resize and SharedArrayBuffer.prototype.grow exist and reject with BufferGrowthAbsentError’s text: <name> is absent from this runtime because a buffer that grows after it is created is allocated outside the memory limit this execution is metered against. Ask for the length you need when you create the buffer.
  • caches exists with the members of CacheStorage, no interface object behind them, and every member rejects with the persistence wording of the chapter below.

Published names answer typeof as if they were served, so detection by typeof enters the guarded branch and meets the rejection there. Only touching a name tells a published absence from a working capability. Capabilities that are merely not built yet, WebSocket for one, stay absent completely and get no entry here.

Setting retainHandles true asks the executor to keep the isolate alive when the run resolves to a fetch handler: a function, or an object carrying a fetch method, in the shape a Workers entrypoint exports. On a grant the result carries no result value, because the envelope is JSON and JSON drops a function: JSON.stringify({ result: () => 1 }) is {}, and an object whose only member is a fetch method comes out as {"result":{}}. Instead the grant carries executionId, together with the handles list of names invokeHandle accepts. That list holds fetch alone today. A result of any other shape returns as usual and nothing is retained, and the absent executionId states that in-band. Retention is a per-run decision tied to reading the granted executionId from the result, so the option lives on the request and the wire call options alone, with no construction-time default.

invokeHandle(executionId, 'fetch', request) builds a Request from the serialized request inside the retained isolate, calls the handler, and answers the serialized Response. An answer that is not a Response, or one that does not fit the response wire shape, is rejected. Names outside the granted list are rejected with HandleNotFoundError’s text: Execution "<executionId>" has no handle named "<handleName>". The granted handles are: <granted>. Module state persists between invocations, and that persistence is the point of retaining it. Invocations run one at a time in arrival order, and a handler’s own throw rejects the invocation with its message while the handles keep serving. Console output during invocations is discarded. The run’s logs were delivered with its result.

Every invocation runs under the bounds its execution was granted, with a fresh bound per invocation and the run clock’s semantics. Guest compute and guest timers spend the bound, a wait served by the caller’s outbound function does not, and the execution’s countHostWaits applies. An invocation that outlives its timeout or exhausts the memory limit reports the corresponding text of the failure grammar and closes the handles, because disposing the isolate is the only way to stop it. Timers the execution created stay alive with its isolate between invocations. Both the memory limit and the idle bound hold whatever they do there, and an isolate they kill reports the memory text on the next invocation.

An invocation reaches what the execution kept. Outbound stays the setting the request chose, and a tool call from a handler answers the completed text of the dispatch grammar, because dispatch belongs to the run that ended. On the wire the handles are keyed on the serving executor, not on the connection, so a fresh connection may keep invoking them. But a caller-supplied outbound function is the exception. The server duplicates that stub and holds the duplicate until the handles close, not until the call returns, so a handler keeps fetching through the caller that started the execution. That duplicate is still a reference into that one session, and it goes when the session does.

Handles end with closeHandles, with the executor or server that holds them, with a crash of the worker under them, at a run bound, or at the idle bound. Retained executions that serve no invocation for the configured idle timeout are closed, and their workers return to rotation. That timeout is handlesIdleTimeout on the pool, and the reference records its default and where it is declared. Every later call meets the HANDLES_CLOSED fault. A retained execution occupies its worker for its whole life, so held handles count against the pool’s capacity.

Nothing survives a call. Every execution runs in a fresh isolate, and the variables, caches and state it built are gone when its result returns. What should outlive a run must come back in the result and arrive again as an input of the next call. Guest code meets the rule in-band, and one exception is negotiated on the request:

  • caches, the global a model reaches for when it wants state to outlive the run, answers every use with CacheStorageAbsentError’s text: Cache storage is absent from this runtime because nothing survives an execution. Return what should persist in the result and take it back as an input on the next call.
  • retainHandles is the one negotiated exception: an execution whose result is a fetch handler may ask to stay alive and serve invocations, under the terms of the chapter above. That exception is bounded and it ends in the HANDLES_CLOSED fault. Whatever an execution builds outside it still dies with the run.

An executor server speaks this protocol alone. Routing by WebSocket subprotocol or by path stays open for wires this protocol does not define, and the room is all that is promised.