Skip to content

Giving a program tools

Programs reach your data through tool calls and through nothing else. You declare what the model is told about, and you serve what it calls. Those are two different jobs, and only the second one decides anything.

One request carries both of them, providers and dispatch:

await executor.execute({
code,
providers: [{ name: 'store', tools: ['listUsers', 'findUser'] }],
dispatch: async (provider, tool, argsJson) =>
JSON.stringify({ result: await serve(provider, tool, argsJson) }),
})

Each declaration becomes a name in the sandbox. store is a Proxy over an empty object, and its get trap answers every property with an async function. Calling one of those sends three strings across the boundary. The dispatch is your function. It takes the provider name, the tool name and the arguments as a JSON string. Back comes a JSON string holding an envelope: {"result": value} for an answer, {"error": text} for a failure the program can catch.

Your function never leaves your process. In the daemon arrangement the program runs on another machine, and every call it makes travels back over the same socket. So the tool still runs where your database credentials are, and the executor holds none of them.

That get trap decides more than it looks like it decides. Tools the declaration never carried are still callable. Each arrives at your dispatch under its own name. A property written onto the proxy beats the trap, and provider source uses that to hang helpers on a namespace. Symbol keys and the property then answer undefined, so awaiting the provider itself yields the proxy and never invokes a tool that was never there:

await executor.execute({
code: 'async () => { const v = await store; return typeof v }',
providers: [{ name: 'store', tools: ['x'] }],
dispatch,
})
// { result: 'object', logs: [] }

Arguments travel as one JSON string, and by default a call spreads them into an array. Models writing ordinary JavaScript produce exactly that:

await executor.execute({
code: `async () => {
await store.noArgs()
await store.two('ada', 7)
await store.obj({ id: 1, deep: { a: [1, 2] } })
await store.bytes(new Uint8Array([1, 2, 3]))
return 'sent'
}`,
providers: [{ name: 'store', tools: ['noArgs'] }],
dispatch: async (provider, tool, argsJson) => {
console.log({ provider, tool, argsJson })
return JSON.stringify({ result: null })
},
})
{ provider: 'store', tool: 'noArgs', argsJson: '[]' }
{ provider: 'store', tool: 'two', argsJson: '["ada",7]' }
{ provider: 'store', tool: 'obj', argsJson: '[{"id":1,"deep":{"a":[1,2]}}]' }
{ provider: 'store', tool: 'bytes', argsJson: '[{"__executor_binary_v1__":"Uint8Array","data":"AQID"}]' }

store.two('ada', 7) arrives as ["ada",7], and store.noArgs() arrives as []. Setting positionalArgs: false on the descriptor picks the other convention, where a tool takes one options object and a call with no argument sends {}:

providers: [{ name: 'store', tools: ['find'], positionalArgs: false }]
// store.find({ id: 7 }) -> argsJson '{"id":7}'
// store.all() -> argsJson '{}'

Pick the one your model already writes for. Dialects that document their tools as taking a single object want false, and a prompt showing ordinary calls wants the default.

__executor_binary_v1__ on the last line of the spread example tags the binary envelope. JSON carries no bytes, so a Uint8Array, an ArrayBuffer or any typed array crosses as a tagged base64 object and is rebuilt as bytes on the far side, in both directions. Never parse that shape by hand. parseDispatchArguments rebuilds it for you, and a Uint8Array returned from your tool reaches the program as a Uint8Array:

dispatch: createEnvelopeDispatch(state, async (provider, tool, argsJson) => {
const [bytes] = parseDispatchArguments(argsJson) as [Uint8Array]
return new Uint8Array([...bytes].reverse()) // arrives inside the sandbox as a Uint8Array
})

A Buffer is a Uint8Array, and it still does not cross as binary. JSON.stringify calls toJSON before any replacer sees the value, and buf.toJSON() answers { type: 'Buffer', data: [...] }. So the codec is handed an ordinary object and adds no tag. stringifyForDispatch below is that codec, exported from @supolka/codemode-executor-protocol beside the two functions the next section uses:

stringifyForDispatch(Buffer.from([1, 2, 3]))
// '{"type":"Buffer","data":[1,2,3]}'
stringifyForDispatch(new Uint8Array([1, 2, 3]))
// '{"__executor_binary_v1__":"Uint8Array","data":"AQID"}'

No error is thrown anywhere along that path, so this one gets found in production. Tools that answer a Buffer hand the program the type and data shape above. Programs written to read bytes read undefined off it. It happens nested too: an avatar column arriving out of a database has this shape inside the sandbox whether or not you thought about it.

Wrap it on the way out, once, wherever your tools return:

const asBytes = (value: unknown): unknown =>
Buffer.isBuffer(value) ? new Uint8Array(value) : value

That copy is a plain Uint8Array, the codec tags it, and the program gets real bytes. Functions cross in neither direction. But when a tool needs to call back into the program, a handle does the job.

Building the envelope is the protocol package’s job, and the lookup and the work are yours:

import {
createEnvelopeDispatch,
parseDispatchArguments,
ToolNotFoundError,
} from '@supolka/codemode-executor-protocol'
const tools = new Map<string, (args: Array<unknown>) => unknown>([
['listUsers', () => store.users()],
['findUser', ([id]) => store.user(String(id))],
])
const state = { active: true }
try {
return await executor.execute({
code,
providers: [{ name: 'store', tools: [...tools.keys()] }],
outbound: null,
dispatch: createEnvelopeDispatch(state, async (provider, tool, argsJson) => {
if (provider !== 'store') {
throw new ToolNotFoundError(tool)
}
const handler = tools.get(tool)
if (handler === undefined) {
throw new ToolNotFoundError(tool)
}
return handler(parseDispatchArguments(argsJson))
}),
})
} finally {
state.active = false
}

createEnvelopeDispatch does three things your own code would otherwise repeat. It serializes whatever the responder returns into {"result": value}, binary included. It turns a rejection into {"error": message}, so a throw inside a tool becomes a catchable error inside the program and not a fault on your side. And it reads the state latch before it serves anything. A call arriving with the latch closed gets the pinned Execution has already completed. and never reaches your data. That latch is a guarantee, not a common event. Programs are free to start a tool call and return without awaiting it:

await executor.execute({
code: `async () => { store.fireAndForget(); return 'returned early' }`,
providers: [{ name: 'store', tools: ['fireAndForget'] }],
dispatch: createEnvelopeDispatch(state, respond),
})
// { result: 'returned early', logs: [] }

store.fireAndForget() is never awaited, and your responder is still entered before execute resolves, because the call leaves the isolate before the arrow returns. That holds in both arrangements. No part of the run waits for the answer, so a responder that takes a round trip to your database is still running when the result reaches you. Models that fire a write and walk away have started that write, so never read an unawaited call as one that did not happen.

But a call arriving genuinely late does not reach you at all. Handlers of a retained execution that call a tool are answered inside the sandbox, and your dispatch is never entered. A timer the program set for after it returned never fires, because the isolate is gone. The latch buys the promise your own code needs while it tears state down in a finally: once you flip it, no code of yours can be entered again on behalf of that run. The whole mechanism is one boolean.

Your dispatch is the boundary, not the declaration

Section titled “Your dispatch is the boundary, not the declaration”

Programs may call a tool the list never carried, and the call lands in your dispatch under that name. They may call a provider nobody declared the same way, because __dispatch takes the provider name as an argument:

await executor.execute({
code: `async () => await __dispatch('secrets', 'read', '[]')`,
providers: [{ name: 'store', tools: ['listOrders'] }],
dispatch: async (provider, tool, argsJson) =>
JSON.stringify({ result: { provider, tool, argsJson } }),
})
// { result: '{"result":{"provider":"secrets","tool":"read","argsJson":"[]"}}', logs: [] }

secrets was never declared. Your dispatch was entered under that name anyway. That behaviour is by design, and @cloudflare/codemode lands an undeclared tool in the host’s own lookup the same way. The declaration says what the model was told about. Your function decides. Key your lookup by the provider and the tool, the way the sample above does, and turn an unknown name down with the declared text:

Tool "<tool>" not found

ToolNotFoundError composes that sentence for you. Every site in the executor that refuses a call composes it from the same class, so the model reads one sentence wherever the refusal came from. A host that routes whatever name arrives has granted that execution everything it can route. Your dispatch is the one boundary the executor cannot hold on your behalf, and no line in the threat model matters more to a host.

Tools that fail are not exceptions on your side and never end the run. A failure becomes a rejection at the call site inside the program, where the model can catch it and work around it:

await executor.execute({
code: `async () => {
try {
return await store.nope()
} catch (failure) {
return { name: failure.name, message: failure.message }
}
}`,
providers: [{ name: 'store', tools: ['listUsers'] }],
dispatch: createEnvelopeDispatch(state, async (provider, tool) => {
throw new ToolNotFoundError(tool)
}),
})
// { result: { name: 'Error', message: 'Tool "nope" not found' }, logs: [] }

Tool "nope" not found reaches the program as an ordinary Error, message and all. Three texts are pinned, so a program can recognize them and a host should not reword them:

What the program catches What caused it
Tool "<tool>" not found The request carried no dispatch at all, or your dispatch turned the name down.
Execution has already completed. The call landed after the run settled. The sandbox answers it on its own, and the latch answers it again.
The dispatch answered outside the result envelope. Your dispatch answered something that is not an envelope object.

That third text is a defect in the host, not in the program. A dispatch answering JSON.stringify('just a string') produces it, since the proxy finds no envelope to unwrap:

await executor.execute({
code: `async () => { try { return await store.x() } catch (failure) { return failure.message } }`,
providers: [{ name: 'store', tools: ['x'] }],
dispatch: async () => JSON.stringify('just a string'),
})
// { result: 'The dispatch answered outside the result envelope.', logs: [] }

Whatever your tool throws keeps its own message, so a domain failure written for the model reaches it unchanged. Arguments a call sends outward are bounded like every other message a run sends. A call whose arguments pass that bound rejects at the call site carrying the payload text and is never delivered. Your dispatch’s answer is not measured against that bound, so a tool that hands back more than the run can return moves the failure to the end of the run.

Two optional fields on a descriptor shape what the model sees. Both are your source, not the model’s, and both run before the program does. prelude runs after every proxy is declared, so a provider can hang helpers on its own namespace. A property written this way beats the get trap and never becomes a tool call:

await executor.execute({
code: 'async () => store.helpers.twice(21)',
providers: [
{ name: 'store', tools: ['listUsers'], prelude: 'store.helpers = { twice: (n) => n * 2 }' },
],
dispatch,
})
// { result: 42, logs: [] }

proxySource replaces the generated proxy outright, so a host can reproduce a namespace shape its model already knows, nested names included. Your source runs in the entry scope and addresses __dispatch and the codec directly:

await executor.execute({
code: 'async () => await api.v1.users.list()',
providers: [
{
name: 'api',
tools: ['v1.users.list'],
proxySource: `const api = { v1: { users: { list: async () => {
const json = await __dispatch('api', 'v1.users.list', __stringifyForDispatch([]))
const data = __parseForDispatch(json)
if (data.error) throw new Error(data.error)
return data.result
} } } };`,
},
],
dispatch: async (provider, tool) => JSON.stringify({ result: `${provider}.${tool} served` }),
})
// { result: 'api.v1.users.list served', logs: [] }

Names that scope provides are contract, and the protocol lists them: __dispatch, the codec pair __stringifyForDispatch and __parseForDispatch, and the codec’s own parts.

Every provider name becomes a const declaration in the entry scope. It has to be an identifier the language allows, it has to be unique, and it may not shadow anything that scope already binds. Every name the generated source itself reads is therefore reserved. RESERVED_PROVIDER_NAMES is exported from the protocol package. The protocol says what the set covers and why each name is in it. Each rule reports its own text inside the envelope before the program runs:

await executor.execute({ code, providers: [{ name: 'console', tools: ['x'] }], dispatch })
// error: 'Provider name "console" is reserved. Give the provider a name the entry scope does not bind.'
await executor.execute({ code, providers: [{ name: 'my-store', tools: ['x'] }], dispatch })
// error: 'Provider name "my-store" must be ASCII letters, digits, underscore and dollar,
// and must not start with a digit.'
await executor.execute({ code, providers: [{ name: 'class', tools: ['x'] }], dispatch })
// error: 'Provider name "class" is on the list this executor refuses: every ECMAScript
// reserved word, the names strict mode reserves, and async. Pick a name outside it.'
await executor.execute({
code,
providers: [
{ name: 'store', tools: ['x'] },
{ name: 'store', tools: ['y'] },
],
dispatch,
})
// error: 'Provider name "store" is declared twice. Give each provider its own name.'

The generated module is strict, so the third rule covers ECMA-262’s keywords and reserved words along with arguments and eval, which strict code may not bind at all. async is on the list too: the language would accept it, but a name that also opens an async function head is too ambiguous to allow. That reserved set does not cover the rest of the standard library, because a provider that shadows fetch breaks no name the executor depends on. But it breaks the program, and silently:

await executor.execute({
code: `async () => { try { const r = await fetch('https://example.com'); return 'reached ' + r.status } catch (failure) { return failure.message } }`,
providers: [{ name: 'fetch', tools: ['x'] }],
dispatch,
})
// { result: 'fetch is not a function', logs: [] }

typeof fetch reads object there, so even a program that checks first takes the wrong branch. Hosts that mint provider names from a model or from user input turn down any name that shadows a global their programs need. The protocol says which names are checked for you.

Tool calls are one direction a program can reach outward, and the network is the other. Decide that one in the network guide. The failures guide says what a caller sees when a call fails, and the pool chapter follows the round trip a call makes between the isolate and your process.