Agent loop with the AI SDK
A model given twenty tools spends twenty round trips to use them. Give it one code tool and it spends one, because it writes a program that calls all twenty. Something then has to run that program. Vercel’s AI SDK declares your tools and drives the step loop. @cloudflare/codemode collapses that tool set into a single codemode tool and writes a TypeScript block into its description for the model to read. This executor runs the program in a V8 isolate, and every call the program makes comes back to a function you wrote. One question goes down that chain and comes back as text, and the sections below walk it hop by hop, in the order the diagram gives:
sequenceDiagram
participant Model
participant SDK as generateText
participant Tool as the codemode tool
participant Adapter as CodemodeExecutor
participant Isolate as V8 isolate
participant Host as your tool functions
SDK->>Model: the prompt, and the type block as the tool description
Model-->>SDK: one tool call carrying the program
SDK->>Tool: execute with the code
Tool->>Adapter: execute(code, providers)
Adapter->>Isolate: the program, the provider names, a dispatch
Isolate->>Host: codemode.listOrders(input)
Host-->>Isolate: two rows
Isolate-->>Adapter: 42
Adapter-->>Tool: the envelope
Tool-->>SDK: the tool result
SDK->>Model: the tool result, as a tool message
Model-->>SDK: the answer
Host in one file
Section titled “Host in one file”Two users, three orders, and a model that writes the arithmetic:
import { registerHooks } from 'node:module'
import { anthropic } from '@ai-sdk/anthropic'import { generateText, isStepCount, tool } from 'ai'import { z } from 'zod'
import { CodemodeExecutor } from '@supolka/cloudflare-codemode-executor'import { IsolateExecutor } from '@supolka/codemode-executor'
registerHooks({ resolve: (specifier, context, nextResolve) => specifier === 'cloudflare:workers' ? { url: 'data:text/javascript,export class WorkerEntrypoint {}', shortCircuit: true } : nextResolve(specifier, context),})
const { aiTools, createCodeTool } = await import('@cloudflare/codemode/ai')
const users = [ { id: 'u1', name: 'Ada' }, { id: 'u2', name: 'Grace' },]
const orders = [ { id: 'o1', userId: 'u1', total: 42, status: 'shipped' }, { id: 'o2', userId: 'u1', total: 7, status: 'pending' }, { id: 'o3', userId: 'u2', total: 100, status: 'shipped' },]
const tools = { listUsers: tool({ description: 'Every user this host knows about.', inputSchema: z.object({}), execute: async () => users, }), listOrders: tool({ description: 'Orders belonging to one user.', inputSchema: z.object({ userId: z.string().describe('The id of the user whose orders to return.'), }), execute: async ({ userId }) => orders.filter((order) => order.userId === userId), }),}
const executor = new CodemodeExecutor(new IsolateExecutor({ outbound: null }))const codemode = createCodeTool({ tools: [aiTools(tools)], executor })
const result = await generateText({ model: anthropic('claude-sonnet-5'), tools: { codemode }, stopWhen: isStepCount(4), prompt: 'What did Ada spend on shipped orders?',})
console.log(result.text)
await executor.dispose()registerHooks is the one line this host would drop on the Workers platform. @cloudflare/codemode/ai re-exports ToolSetConnector, whose base class extends WorkerEntrypoint from cloudflare:workers, and Node resolves no such specifier. module.registerHooks points it at a stub instead, and the import under it has to be dynamic, because Node resolves a static one before the first line of the file runs. A bundler that can alias a module specifier does the same job at build time.
aiTools returns a provider, and createCodeTool takes providers in an array, so the wrapper goes inside brackets. Three calls under it are ordinary AI SDK: tool() returns its argument untouched and exists to infer the type of the execute parameter, isStepCount(4) lets the loop run four steps, and anthropic('claude-sonnet-5') names a model id the provider package declares. Every printout below came from this host with that model replaced by the SDK’s own mock, as Testing without a key shows.
Types the model reads
Section titled “Types the model reads”Your tools appear in one place, this tool description:
Execute code to achieve a goal.
Available:type ListUsersInput = {}type ListUsersOutput = unknowntype ListOrdersInput = { /** The id of the user whose orders to return. */ userId: string;}type ListOrdersOutput = unknown
declare const codemode: { /** * Every user this host knows about. */ listUsers: (input: ListUsersInput) => Promise<ListUsersOutput>;
/** * Orders belonging to one user. * @param input.userId - The id of the user whose orders to return. */ listOrders: (input: ListOrdersInput) => Promise<ListOrdersOutput>;}
Write an async arrow function in JavaScript that returns the result.Do NOT use TypeScript syntax — no type annotations, interfaces, or generics.Do NOT define named functions then call them — just write the arrow function body directly.
Example: async () => { const r = await codemode.searchWeb({ query: "test" }); return r; }generateTypes builds that block out of the same schemas your tools declare, so it cannot drift from them. But it describes no shape for what a tool returns until you add an outputSchema, and ListOrdersOutput is unknown for exactly that reason. Add one and the model gets the field names it is about to filter on. That costs one schema. The @param line under listOrders comes from the .describe() on that field, and a field without one gets no line. codemode is the namespace a provider that declares no name of its own goes under.
Program the model writes
Section titled “Program the model writes”Read the program off the step:
console.log(result.staticToolCalls[0]?.input.code)async () => { const orders = await codemode.listOrders({ userId: 'u1' })
return orders .filter((order) => order.status === 'shipped') .reduce((total, order) => total + order.total, 0)}codemode is a Proxy the executor generates inside the isolate from the provider names CodemodeExecutor sent. Its get trap turns listOrders into an async function, and calling that function sends three strings across the boundary and waits for the JSON that comes back. Declaration and dispatch has that trap in full.
Dispatch into your process
Section titled “Dispatch into your process”Subclass the native executor to watch those three strings:
import type { ExecuteRequest, ExecuteResult } from '@supolka/codemode-executor'
class SeenDispatch extends IsolateExecutor { async execute(request: ExecuteRequest): Promise<ExecuteResult> { const dispatch = request.dispatch
if (dispatch === undefined) { return await super.execute(request) }
return await super.execute({ ...request, dispatch: async (provider, name, argsJson) => { console.log(provider, name, argsJson)
return await dispatch(provider, name, argsJson) }, }) }}codemode listOrders [{"userId":"u1"}]argsJson is the argument list as one JSON array, and CodemodeExecutor spreads it back into the call, so listOrders gets the single object its schema describes. Validation runs first. The ai entry point of @cloudflare/codemode wraps every tool in its own inputSchema, so a program passing the wrong shape gets the schema’s message thrown back at the call site. Values that cross a tool call has what survives that round trip in either direction. An uncaught rejection comes out through the code tool:
Error: Code execution failed: [ { "expected": "string", "code": "invalid_type", "path": [ "userId" ], "message": "Invalid input: expected string, received number" }]Envelope the tool returns
Section titled “Envelope the tool returns”One number comes back, inside the envelope @cloudflare/codemode defines:
{ result: 42 }No logs key appears here, because this program wrote nothing to console. runCode adds the key only when the executor sent lines back. @cloudflare/codemode writes the level into the text and this executor keeps it in a field, so CodemodeExecutor renders one into the other. Each console call becomes one string:
{ result: 1, logs: [ 'plain', '[warn] careful', '[error] broken' ] }Answer the model writes
Section titled “Answer the model writes”That envelope goes to the model as a tool message:
{ role: 'tool', content: [ { type: 'tool-result', toolCallId: 'call-1', toolName: 'codemode', output: { type: 'json', value: { result: 42 } } } ]}output.type is json for a program that returned, and the model reads the whole envelope, result key included. That second model call happens only because this host passed isStepCount(4), so the model writes its text and generateText returns it as result.text:
Ada has 42 in shipped orders.stopWhen defaults to a single step. Drop it and the same host stops a step short:
{ steps: 1, text: '', toolResults: 1 }toolResults is still 1 there. Your program ran and it called your tools, but the model never saw the number.
Three decisions the host made without noticing
Section titled “Three decisions the host made without noticing”outbound: null is the network policy of this host, and it is the whole of it. Leave it out and the executor performs whatever request the program asks for, from the machine it runs on, with the network position that machine has. @cloudflare/codemode ships DynamicWorkerExecutor for Workers, and that one blocks outbound unless a host opts in. So the value that reads like a no-op is the one that reverses on the swap, and Outbound reverses on the swap has the table. Note where the setting sits. createCodeTool passes no execute options at all, so the constructor is the only place this host can name it. Routing outbound through a function of your own instead is Writing a policy.
Whatever record you hand aiTools is both what the model is told about and what it can call. Declaration and boundary are one here. createCodeTool drops every tool that declares needsApproval, generates the type block from what is left, and gives that same set to the executor as one provider. A program that calls a name outside it finds nothing in the handler map CodemodeExecutor built, and the refusal arrives at the model as the output of its tool result:
{ type: 'error-text', value: 'Error: Code execution failed: Tool "listInvoices" not found'}But that equality between declaration and boundary belongs to this path and not to the executor under it. On the native contract a name outside the list still arrives at your dispatch, and your dispatch decides, per Undeclared names reach your dispatch.
Every run of this host is bounded at 60 seconds, and no line of the host says so. DEFAULT_TIMEOUT in the protocol is 60000 milliseconds, and IsolateExecutor applies it to any request that names no timeout of its own. So the number moves in the same constructor as the network setting, new IsolateExecutor({ outbound: null, timeout: 10_000 }). But that clock counts more than the program: CodemodeExecutor asks the executor to count host waits, so the seconds your own tool functions spend working come out of the same budget. Timeout says what a program meets when the bound expires, and Run clock says what the clock counts.
Over a daemon
Section titled “Over a daemon”Move the pool onto a daemon and one line changes:
import { CodemodeExecutor, connect } from '@supolka/cloudflare-codemode-executor'
const executor = new CodemodeExecutor( await connect(process.env.EXECUTOR_URL ?? 'ws://127.0.0.1:8080', { outbound: null }),)connect returns an executor behind the same Executor interface, so createCodeTool, generateText and both tool declarations stay exactly as they were. Your tool functions do not move either. Run it as a service covers starting a daemon and keeping the connection to it. The program runs over there, each call it makes travels back over the socket it arrived on, and listOrders still executes beside your database credentials:
listOrders ran in the host process for u1{ result: 42 }Ada has 42 in shipped orders.Testing without a key
Section titled “Testing without a key”Swap anthropic('claude-sonnet-5') for a mock and the loop needs no key:
import { MockLanguageModelV4 } from 'ai/test'
const model = new MockLanguageModelV4({ doGenerate: [ { content: [ { type: 'tool-call', toolCallId: 'call-1', toolName: 'codemode', input: JSON.stringify({ code: program }), }, ], finishReason: { unified: 'tool-calls', raw: 'tool_use' }, usage: { inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 }, outputTokens: { total: 0, text: 0, reasoning: 0 }, }, warnings: [], }, { content: [{ type: 'text', text: 'Ada has 42 in shipped orders.' }], finishReason: { unified: 'stop', raw: 'end_turn' }, usage: { inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 }, outputTokens: { total: 0, text: 0, reasoning: 0 }, }, warnings: [], }, ],})doGenerate takes an array, one entry per model call, so two entries make one tool call and one reply. program is the string a model would have written, so a suite pins the programs it wants covered and runs each one through the real executor. Under that tool call the machinery is real: the type generation, the isolate, the dispatch into your functions, the envelope, and the tool message a model would read next. Only the model’s judgement is missing, and model.doGenerateCalls records every call the SDK made, so a test can assert on the type block the model was shown.
That whole loop is @cloudflare/codemode end to end, with this executor under it in place of the Workers platform. Replacing DynamicWorkerExecutor is the rest of the swap, for a host already running that package on Workers. A host with no such compatibility to keep writes against the executor protocol instead, and that contract has the fields the code tool never passes: the module map, a retained execution, per-request bounds.