Handles
Nothing survives an execution, per the persistence contract. retainHandles is the one negotiated exception: set it, and a run that returns a request handler keeps its isolate alive for your host to invoke later.
Why handles exist
Section titled “Why handles exist”Your host routes requests to a handler the model wrote, so the program runs once for all of them. Running it again repeats the setup and throws away what the first run built: the catalogue it loaded, the index it computed, the client it configured. Retain a handle when the setup costs more than the request that follows it, and when you have traffic to route. One retained isolate, from the run that grants the handle to the call that disposes it:
sequenceDiagram
participant H as your host
participant E as the executor
participant I as the isolate
H->>E: execute, retainHandles true
E->>I: run the program, with your dispatch live
I-->>E: a fetch handler
E-->>H: executionId, and the granted handle names
Note over H,I: the run has ended, and your dispatch with it
H->>E: invokeHandle, once per request
E->>I: the handler, under a fresh bound
I-->>E: the response fields
E-->>H: the response
H->>E: closeHandles
E->>I: dispose
Asking for a handle
Section titled “Asking for a handle”const outcome = await executor.execute({ code: `async () => { const catalogue = await store.listProducts() let hits = 0
return { fetch: async (request) => { hits += 1 const id = new URL(request.url).searchParams.get('id')
return Response.json({ hits, item: catalogue.find((item) => item.id === id) ?? null }) }, } }`, providers: [{ name: 'store', tools: ['listProducts'] }], dispatch, retainHandles: true,}){ result: undefined, logs: [], executionId: 'd6201803-2236-4c65-be08-ff9eaca95a75', handles: [ 'fetch' ]}Two result shapes grant a handle: a function, or an object with a fetch method, the shape a Workers entrypoint exports. A bare async (request) => new Response('…') is retained exactly like the object above. In place of a result the grant returns that executionId and the names invokeHandle accepts, because a function cannot cross the boundary as a value. But results of any other shape return as usual and retain no isolate, and the absent executionId states that in-band. Branch on the executionId you got back, not on the retainHandles you sent. Logs of the run itself return with the grant, the way they do for any other run.
State the handler keeps
Section titled “State the handler keeps”Handlers keep whatever the arrow closed over. Because the entry contract is one expression, a program writes no statements before the arrow and has no module scope of its own. State lives in the arrow’s body, or in a module the program imported:
// state in the arrow's bodycode: `async () => { let served = 0; return { fetch: async () => Response.json({ served: ++served }) } }`// invocation 1 -> {"served":1}// invocation 2 -> {"served":2}
// state in an imported modulecode: `import { bump } from './counter.js'
async () => ({ fetch: async () => Response.json({ served: bump() }) })`modules: { './counter.js': `let served = 0export const bump = () => (served += 1)` }// invocation 1 -> {"served":1}// invocation 2 -> {"served":2}served survives between invocations because the isolate does. Timers stay alive with it, so a counter a setInterval drives keeps climbing while nobody calls. Two bounds limit what those timers can do: the memory limit, and the idle bound that closes an execution nobody invokes. An isolate that fills its heap between invocations reports the memory text on the next one.
Invoking a handle
Section titled “Invoking a handle”const answer = await executor.invokeHandle(outcome.executionId, 'fetch', { method: 'GET', url: 'https://catalogue.internal/item?id=42', headers: [], body: null,}){ status: 200, statusText: '', headers: [ [ 'content-type', 'application/json' ] ], body: ArrayBuffer // '{"hits":1,"item":{"id":"42","name":"hat"}}'}Requests and responses take the same wire shapes an outbound policy uses, so a host that already translates between its own framework and those types reuses the translation. An invocation returns those four fields and no others, so whatever the handler writes to console is discarded. Bodies travel as an ArrayBuffer in both directions:
await executor.invokeHandle(outcome.executionId, 'fetch', { method: 'POST', url: 'https://host/items', headers: [['content-type', 'application/json']], body: new TextEncoder().encode('{"id":7}').buffer,})// a handler echoing the request sends back// {"method":"POST","type":"application/json","body":{"id":7}}Inside the isolate the handler receives a real Request and must return a real Response. The host checks that value again on its own side, because a program can redefine Response inside its own isolate. Two texts report a wrong one:
The fetch handle answered with a value that is not a Response.The fetch handle answered outside the response wire shape.invokeHandle refuses a name outside the granted list without entering the handler, and the refusal’s second sentence names the list this execution granted:
Execution "<id>" has no handle named "scheduled". The granted handles are: fetch.
Error codes and texts has the other branch of that sentence, for a grant with an empty list. Refusing a name leaves the handles open. So does a handler’s own throw: it rejects that one invocation with the handler’s message, and the next request still runs. Invocations of one execution run one at a time, in the order they arrive.
Limits an invocation runs under
Section titled “Limits an invocation runs under”The run that granted the handles has ended. Its dispatch ended with it. A tool call from inside a handler comes back with the pinned completion text, and your dispatch never runs:
code: `async () => ({ fetch: async () => { try { await store.listProducts() } catch (failure) { return new Response(failure.message) } } })`// the invocation body reads: Execution has already completed.Setup happens during the run. Load whatever the handler needs from your side before the arrow returns. Every invocation runs under the execution’s own timeout and memory limit, and each one starts that bound again. Outbound stays the setting the request chose, so a handler can still fetch if you left it open. But one exception applies over the wire. A caller-supplied outbound function crosses as a stub of the connection the execute arrived on, so a handler’s fetch through it fails once that connection rotates or closes.
Ways a retained isolate ends
Section titled “Ways a retained isolate ends”Four things end a retained isolate:
| What ends it | What happens |
|---|---|
closeHandles |
Your host calls it, and the child disposes the isolate. |
| The idle bound | No invocation arrives for as long as the pool’s handlesIdleTimeout, and the pool closes the handles. Default: five minutes, per the published API. |
| A run bound | An invocation outlives its timeout or fills its memory limit, and rejects with the declared text the bounds guide quotes for that bound. Disposing the isolate is the only way to stop it, so the whole execution ends with that one call. |
| The worker dying, or the pool shutting down | Both publish the close, so a host that reserved something for that execution can release it. |
The pool returns the worker to rotation after the first three of those, and replaces it after a crash. closeHandles is the one your host calls:
await executor.closeHandles(outcome.executionId)After any of the four, invokeHandle and closeHandles both reject with the same fault:
ExecutorError | HANDLES_CLOSED | retryable falseExecution "<id>" holds no handles: they were closed, expired at the idle bound, or never granted.Execute again with retainHandles to get a fresh executionId.That fault also comes back for an executionId nobody ever granted. So a host cannot tell a closed execution from an invented one, and neither can a caller probing for identifiers.
Who can invoke a handle
Section titled “Who can invoke a handle”Whoever has the executionId can invoke a handle. The process that ran the isolate mints it with crypto.randomUUID, and the executor checks no more than that string on the way in. Callers of one daemon share one trust domain. Its onConnection hook names who called, and that name limits no call it lets in. So a deployment whose callers distrust each other runs a daemon for each, or authorizes at the gateway in front. The threat model says the same, among the controls the operator owns. Over the wire the handles are keyed on the serving executor and not on the connection, so a fresh connection may keep invoking them. Behind a load balancer the caller has to reach the same daemon, because each retained isolate lives on one machine.
Cost of a retained execution
Section titled “Cost of a retained execution”Retained executions occupy their worker’s slot until they end, so a deployment that grants handles counts them in its worker budget instead of in its throughput. Eight retained executions on a pool of eight workers leave no worker free for a computation, and the callers meet the queue bound. Close them when you are done. Put the call in a finally wherever the routing has an end. Where your programs are computations, retain none of them and let each isolate go. The pool chapter says how a retained execution occupies a worker, and what the pool publishes when one closes.
Handles are the last capability the guides hand out. Agent loop with the AI SDK is one host end to end, where a model drives your tools and your bounds through a single code tool. Replacing DynamicWorkerExecutor is for a host already running @cloudflare/codemode, and it takes up the one default that reverses on the swap. The executor protocol is the contract the whole section rests on.