Executor protocol
Host implementers read this contract while writing the code that calls the executor. The model whose program is running reads part of it during the run: every declared failure here is a sentence composed for whatever writes the next attempt. So every failure text below is quoted as the code composes it.
@supolka/codemode-executor-protocol is the machine-readable half, a package with no dependencies that both sides of the wire implement. Two executors implement it: 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 that package alone. Every bound below is a safety claim, the timeout and the memory limit above all, and the threat model names the boundary that enforces each one.
One contract in two forms
Section titled “One contract in two forms”In-language the contract is three declarations:
type ExecuteRequest = { code: string providers?: Array<ProviderDescriptor> dispatch?: ToolDispatchFn modules?: ModuleMap timeout?: number | null countHostWaits?: boolean outbound?: OutboundFetchFn | null retainHandles?: boolean}
type ExecuteResult = { result?: unknown error?: string logs?: Array<LogLine> executionId?: string // present only when retainHandles was honoured handles?: Array<string> // the handle names invokeHandle accepts}
interface Executor { execute(request: ExecuteRequest): Promise<ExecuteResult> invokeHandle( executionId: string, handleName: string, request: OutboundRequestMessage, ): Promise<OutboundResponseMessage> closeHandles(executionId: string): Promise<void> dispose(): Promise<void>}code is the only field a request must set. Seven fields beside it state the rest of one execution: the provider descriptors, the dispatch function, the module map, the two clock settings, the outbound setting and the retention flag. Defined fields win over the executor’s construction-time default, and an undefined field reads the same as an absent one. On the wire the contract is the ExecutorApi RPC surface, spoken over a WebSocket that runs a capnweb session:
type ExecutorApi = { protocolVersion(): Promise<number> execute( code: string, providers: Array<ProviderDescriptor>, options: ExecuteCallOptions, dispatch: ToolDispatchFn | null, // null when the request carries no tools outboundMode: OutboundMode, outbound?: OutboundFetchFn, // present only when outboundMode is 'stub' ): Promise<ExecuteResultMessage> invokeHandle( executionId: string, handleName: string, request: OutboundRequestMessage, ): Promise<OutboundResponseMessage> closeHandles(executionId: string): Promise<void>}The connecting side calls protocolVersion() before anything else crosses the connection and compares it against its own for exact equality. PROTOCOL_VERSION in the protocol package is the value a build speaks, and the reference states it. dispatch and outbound cross as live RPC stubs, so the provider implementations never leave the caller’s process. Requests without tools send null for the dispatch, and the sandbox then sends the not-found envelope to every tool call.
invokeHandle calls one handle of a retained execution with a serialized request and returns the serialized response. closeHandles releases the execution. Both reject with the HANDLES_CLOSED fault once the handles are gone, and Execution handles states that lifecycle.
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. Module maps cross 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.
Entry contract
Section titled “Entry contract”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. The entry module wraps that body and rewrites nothing in it, so a program outside the form reports its own syntax error inside the result envelope. Turning raw model output into this form belongs to the caller, and the codemode adapter does it for its dialect.
An import declaration is legal only at module scope, while the arrow becomes a function body. So the executor lifts the leading import block out, and the body reads its bindings through the closure. The lift works a line at a time, and it takes a leading import line whole, trailing code included:
const modules = { greeter: `export const greet = (name) => 'hello ' + name` }
await executor.execute({ code: `import { greet } from 'greeter'async () => greet('ada')`, modules,})// { result: 'hello ada', logs: [] }
await executor.execute({ code: `import { greet } from 'greeter'; const size = 3async () => greet('ada').repeat(size).length`, modules,})// { result: 27, logs: [] }
await executor.execute({ code: `import { greet } from 'greeter'; async () => greet('ada')`, modules,})// { result: undefined, error: "Unexpected token ')' [executor.js:63:1]", logs: [] }const size = 3 reaches module scope on the import line, and the arrow still closes over it. The lift takes the third arrow with its import and leaves the body empty. Its syntax error points into the generated entry module, not into anything the model wrote. So the caller writes the arrow on a line of its own.
Providers and the sandbox scope
Section titled “Providers and the sandbox scope”Every provider descriptor names the provider and lists its tool names. Three members beside those two are optional:
type ProviderDescriptor = { name: string tools: Array<string> positionalArgs?: boolean // how the generated proxy passes call arguments prelude?: string // source that runs after every proxy is declared proxySource?: string // source that replaces the generated proxy for this provider}tools records what the caller told its model about. But every property goes through the generated proxy’s get trap, so a tool name outside that list still arrives at the dispatch. __dispatch takes the provider name as an argument, so a name no descriptor declared arrives there too:
const seen: Array<[string, string]> = []
await executor.execute({ code: `async () => { await store.listOrders() await store.deleteOrders() await __dispatch('crm', 'listOrders', '')
return 'every call reached the dispatch' }`, providers: [{ name: 'store', tools: ['listOrders'] }], dispatch: async (provider, tool) => { seen.push([provider, tool])
return JSON.stringify({ result: null }) },})// { result: 'every call reached the dispatch', logs: [] }
seen// [ [ 'store', 'listOrders' ], [ 'store', 'deleteOrders' ], [ 'crm', 'listOrders' ] ]deleteOrders and crm appear in no descriptor, and both arrive at the dispatch function. Only that function knows what it is willing to serve, so the decision belongs there. But the names themselves are checked. validateProviders in the protocol package tests every one against four rules. A name is ASCII letters, digits, underscore and dollar, and it does not start with a digit. It is not reserved by the entry scope, not an ECMAScript reserved word, and not a duplicate. Each of the four rules has its own error class in the protocol package, and that class composes the text.
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 returns a promise for the envelope JSON.__stringifyForDispatchand__parseForDispatchare the dispatch codec.__BINARY_TAG,__encodeBinaryValue,__decodeBinaryValue,__encodeBase64and__decodeBase64are the codec’s parts, present because the codec is assembled from them in that scope.
The protocol reserves every one of those names as a provider name. It reserves the names the generated source itself depends on too, together with the host bindings, timers and codec globals the sandbox installs. packages/protocol/src/provider-name.ts declares the whole set, grouped by the reason each name is in it. No declaration may shadow any of them.
Reserved names cover what the generated source would break on, not the whole standard library, and the gap is deliberate. fetch and Response sit outside the set, so a provider may take either name and shadow that global inside the entry module for the whole run. Nothing outside the entry module changes 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 fetch or Response, and its contents are the caller’s decision.
Dispatch grammar
Section titled “Dispatch grammar”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. A tool’s return value travels back the same way, as one JSON string. Its envelope is {"result": value} where the tool returned, {"error": text} where it threw, and the sandbox proxy rethrows that error text behind the boundary.
stringifyForDispatch(['ada', 3])// ["ada",3]
stringifyForDispatch({ customer: 'ada' })// {"customer":"ada"}
const answering = createEnvelopeDispatch({ active: true }, async () => ({ id: 'o1', total: 42 }))
await answering('store', 'listOrders', '["ada",3]')// {"result":{"id":"o1","total":42}}
const throwing = createEnvelopeDispatch({ active: true }, async () => { throw new Error('No such customer.')})
await throwing('store', 'listOrders', '')// {"error":"No such customer."}
const settled = createEnvelopeDispatch({ active: false }, async () => undefined)
await settled('store', 'listOrders', '')// {"error":"Execution has already completed."}createEnvelopeDispatch composes all three envelopes. The completed envelope is the pinned one. Calls that arrive after their execution finished read Execution has already completed. wherever they were made. ToolNotFoundError composes the pinned text Tool "<tool>" not found for a call naming a tool nobody implements. A return value that is not an envelope object is a protocol fault, and the generated proxy rethrows it as The dispatch answered outside the result envelope. JSON has no binary type, so a Uint8Array, an ArrayBuffer or a typed array crosses as a base64 envelope tagged with the string __executor_binary_v1__:
stringifyForDispatch({ result: new Uint8Array([1, 2, 3, 255]) })// {"result":{"__executor_binary_v1__":"Uint8Array","data":"AQID/w=="}}
stringifyForDispatch({ result: new Uint16Array([1, 2]) })// {"result":{"__executor_binary_v1__":"ArrayBufferView","data":"AQACAA=="}}That Uint16Array crosses under the tag ArrayBufferView. Decoding turns both tags into bytes, so a typed array other than Uint8Array decays to plain bytes on the way through.
The sandbox installs the whole console namespace of the Console Standard. It captures log, warn and error and sends them 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. Three Node additions stay out, because each returns an object this runtime has no way to shape. Node documents one of them, the Console constructor, and ships console.context and console.createTask with no documentation at all.
type LogLine = { level: 'log' | 'warn' | 'error' text: string}text is the call’s arguments stringified and joined with single spaces. Readers decide what to render from level, and the codemode adapter, for one, renders its dialect’s prefixes from it.
CONSOLE_CAPTURE_LIMIT bounds the captured text of one whole execution. Each line is charged what it costs on the wire, its own shape included. So a run writing empty lines fills that bound like any other, and the line that meets it is dropped whole.
Everything a run sends is bounded
Section titled “Everything a run sends is bounded”Anything a run sends outward becomes host memory in the executor and a message it has to deliver, and each transport in the path caps one message. Past that cap the transport under the run drops the message and fails. So the executor bounds each of these where it is built, and the failure goes to the run instead of to the transport:
| Part | What counts toward the bound |
|---|---|
run's answer |
The result, or the thrown message, whichever the run ended with. |
tool call |
The provider name, the tool name and the arguments together. |
outbound request |
The URL, the headers and the body. |
handle answer |
The status text, the headers and the body. |
PayloadTooLargeError reports a part that went past the bound, and its text names that part, its size and the bound it passed. PAYLOAD_LIMIT in the protocol package is the bound itself, 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. 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 with CONSOLE_CAPTURE_MESSAGE takes the 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 and keeps its result.
Outbound setting
Section titled “Outbound setting”A tri-state setting decides where sandbox fetch goes, and the request field maps onto one wire mode:
outbound on the request |
Mode on the wire | What the mode means |
|---|---|---|
| Absent | native |
The executor performs the request in its own process, unless the server’s operator vetoed that mode. |
| A function | stub |
The executor delivers every request to that function as a serialized exchange, request and response as plain data. |
null |
disabled |
No network at all. A fetch call rejects with the outbound text of the failure grammar. |
That serialized exchange is two plain objects and the function between them:
type OutboundRequestMessage = { method: string url: string headers: Array<[string, string]> body: ArrayBuffer | null}
type OutboundResponseMessage = { status: number statusText: string headers: Array<[string, string]> body: ArrayBuffer}
type OutboundFetchFn = (req: OutboundRequestMessage) => Promise<OutboundResponseMessage>A request body crosses as an ArrayBuffer or as null, and a response body always as an ArrayBuffer. Each mode names the caller’s intent instead of leaving it to be read off whether an argument arrived, and the server switches on the name. stub duplicates the caller’s function and routes every request back through it. 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. The server refuses a mode it does not recognize with PROTOCOL_MISMATCH, and a tri-state read off the arguments would have no unrecognized case to refuse.
Timeouts and the run clock
Section titled “Timeouts and the run clock”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 run clock measures that bound. It stops while the only running work is a tool call or a request to the caller’s outbound function, and it runs again when guest code runs. So one question separates a host wait from the rest: whose work the wait is.
| Work in flight | Spends the bound |
|---|---|
| Guest compute | Yes, and it counts while a host call is open |
A guest setTimeout delay with no host call open |
Yes |
A guest setTimeout delay while a host call is open |
No |
| Whatever a timer callback does when it fires | Yes |
| A tool call | No |
| A request to the caller’s outbound function | No |
| A request the executor performs natively | Yes |
Cryptography the host performs behind crypto.subtle |
Yes |
The last two rows are the guest’s own work done elsewhere. Native requests go to a URL the guest chose, and nothing but the run bounds them. Tools may take ten minutes to reply, an approval flow above all, without ending a run whose own work stayed inside the bound.
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 wall-clock time. But a run whose clock is stopped still occupies its worker, and the caller bounds tool and outbound latency. An executor out of capacity turns new work away with the queue fault and never ends a run that is waiting.
Failure grammar
Section titled “Failure grammar”Two readers meet the executor’s failures, and the primary one is the model whose code just ran. Declared failures go to the model: the executor turning something down on purpose, a bound enforced or a surface declared absent. Faults go to the calling application: 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. One grammar governs every one of those texts: 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 a direct test pins each text. 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 pass the message on as text.
| Class | Text | Where it arrives |
|---|---|---|
ExecutionTimeoutError |
Execution timed out at its <timeout> ms bound. Do less work per run and return the part that is already finished. |
The result envelope’s error. |
MemoryLimitError |
Execution ran out of memory at its <memoryLimit> MB limit. Work through the data in smaller pieces and keep only what you return. |
The result envelope’s error. |
OutboundDisabledError |
Outbound fetch is disabled for this execution. Anything this run needs from outside has to arrive through a tool call. |
The rejection of the fetch call. |
ModuleNotFoundError |
Module "<name>" is not in the module map. Import one of the names it declares: <declared>. |
A dynamic import() rejects with it, so the program can catch it. A static import resolves before the entry ever runs, so the run never starts and the text arrives in the envelope. |
ExecutionTimeoutError’s text opens with the pinned dialect sentence Execution timed out, and ExecutionTimeoutError.matches tests a text against that whole first sentence. Adapters recognize the timeout that way.
Two more sets of classes report inside the envelope. ModuleKeyCollisionError and UnsupportedModuleKindError cover a map whose keys collide after normalization and an entry with no supported kind. Provider name classes cover a list that cannot become the entry’s declarations, one class per rule Providers and the sandbox scope states. Declared absences and the persistence contract state their own texts, and the sandbox throws each of those where the program touched the name.
A fault throws. Every thrown failure has a code property from the taxonomy in the protocol package. A code exists there 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 an unknown outbound mode arrives on the wire. |
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 response, 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 with the message and any own properties the thrown value had. But the class, the name and the stack do not cross. Of the three, the stack is the one worth knowing about, because the caller does read one. That stack was manufactured where the error arrived, and it names the transport, not the line that threw. So the code property is the only way back to a typed error: a consumer branches on the code, and rehydrateExecutorError rebuilds the instance from it. An error with no code from the taxonomy passes 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 texts 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.
Declared absences
Section titled “Declared absences”Surfaces this runtime leaves out on purpose still exist under their own names. Each fails at the first touch instead of reading as a bare undefined, so the code that touched it learns why and what remains possible. Every failure is a DOMException named NotSupportedError. Both constructors and both growth operations throw it synchronously, and the caches members reject with it. Three error classes compose the texts:
CompressionStreamandDecompressionStreamexist, and construction throwsCompressionAbsentError’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.resizeandSharedArrayBuffer.prototype.growexist, and a call to either throwsBufferGrowthAbsentError’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.cachesexists with the members ofCacheStorage, no interface object behind them, and every member rejects with the persistence wording of the chapter below.
typeof on a published name reads as if the capability were there, so detection by typeof enters the guarded branch and meets the failure inside it:
await executor.execute({ code: `async () => [typeof caches, typeof CompressionStream, typeof WebSocket]`, outbound: null,})// { result: [ 'object', 'function', 'undefined' ], logs: [] }caches reads object and CompressionStream reads function, so only touching a name tells a published absence from a working capability. WebSocket reads undefined because this runtime neither implements it nor declares it absent, and a capability outside both stays absent completely and gets no entry here.
Execution handles
Section titled “Execution handles”Setting retainHandles true asks the executor to keep the isolate alive when the run resolves to a fetch handler. That handler is a function, or an object with a fetch method, in the shape a Workers entrypoint exports. On a grant the result has 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":{}}. The grant returns executionId instead, together with the handles list of names invokeHandle accepts. Results of any other shape return 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.
stateDiagram-v2 [*] --> Retained: the run resolved to a fetch handler under retainHandles Retained --> Retained: invokeHandle, one at a time in arrival order Retained --> Closed: closeHandles, or the executor or server holding them shuts down Retained --> Closed: the idle bound expires, or a run bound ends an invocation Retained --> Closed: the worker under the execution crashes Closed --> [*]: every later call meets HANDLES_CLOSED
invokeHandle(executionId, 'fetch', request) builds a Request from the serialized request inside the retained isolate, calls the handler, and returns the serialized Response. It rejects a return value that is not a Response, and one that does not fit the response wire shape. It rejects a name outside the granted list 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, because 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 inside 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. Disposing the isolate is the only way to stop it. Timers the execution created stay alive with its isolate between invocations, and the memory limit is the one bound that ends what they do. An isolate that limit kills reports the memory text on the next invocation. The idle bound takes the other path: it closes the execution, and the next call meets the HANDLES_CLOSED fault.
An invocation reaches what the execution kept. Outbound stays the setting the request chose. A tool call from a handler gets the completed text of the dispatch grammar, because dispatch belongs to the run that ended. On the wire the handles are keyed on the executor itself, 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 keeps 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.
handlesIdleTimeout on the pool is the idle bound, and the reference records its default and where it is declared. Retained executions that nobody invokes for that long are closed, and their workers return to rotation. Until then each of them occupies its worker, so held handles count against the pool’s capacity.
Persistence contract
Section titled “Persistence contract”Nothing survives a call. What should outlive a run comes back in the result and arrives again as an input of the next call, and guest code meets that rule in-band.
| Execution | What outlives it |
|---|---|
| Every execution | The result envelope and nothing else. The isolate is fresh, and the variables, caches and state the run built are gone when its result returns. caches, the global a model reaches for when it wants state to outlive the run, rejects 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. |
One that asked for retainHandles and resolved to a fetch handler |
Its isolate, for as long as it serves invocations under the terms of the chapter above. That exception is the only one a request can negotiate, it is bounded, and it ends in the HANDLES_CLOSED fault. Whatever an execution builds outside it still dies with the run. |
Room left for other wires
Section titled “Room left for other wires”An executor server speaks this protocol alone. Room stays open beside it for wires this protocol does not define, routed by WebSocket subprotocol or by path.