Skip to content

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.

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
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.

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 body
code: `async () => { let served = 0; return { fetch: async () => Response.json({ served: ++served }) } }`
// invocation 1 -> {"served":1}
// invocation 2 -> {"served":2}
// state in an imported module
code: `import { bump } from './counter.js'
async () => ({ fetch: async () => Response.json({ served: bump() }) })`
modules: { './counter.js': `let served = 0
export 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.

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.

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.

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 false
Execution "<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.

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.

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.