Skip to main content
An agent loop calls a model, runs the tools that the model asks for, and repeats. This guide shows you how to keep each agent session in one Durable Object. The object holds the conversation, the tool calls, and the run status in SQLite, and its alarm moves the loop forward one step at a time. A crash or a restart of celld, the Judt runtime, then resumes the loop from the saved state. Use this pattern for interactive agents that need chat, live progress, pause, cancel, or approval. For a batch job with no user interaction, you can also use a Workflow. For more information, see Workflows. The examples use the Anthropic Messages API. The same structure works with other model providers that you call over HTTP with fetch.

Before you begin

Make sure that you have the following:
  • A Secret that holds the API key of your model provider. The examples read it as env.ANTHROPIC_API_KEY. For more information, see Vars and Secrets.
  • A working knowledge of Durable Objects, including SQLite storage, alarms, and WebSockets. For more information, see Durable Objects.

How the loop works

The design follows these rules:
  • One object for each session. Your Worker addresses a session with env.AGENTS.getByName(sessionId).
  • Only the alarm handler runs steps. RPC methods and the fetch handler change the status and set the alarm, but they never call the model. Alarms of one object don’t overlap, so two steps can’t run at the same time.
  • One unit of work for each alarm. A unit is one model call, or the tool calls of one model response. Each unit must finish within the 300-second limit for one handler call, and one fetch has a 120-second limit.
  • Save before you continue. The object saves the model response and its tool calls before it runs tools. It saves each tool result before the next model call. After a crash, the alarm runs again and continues from the saved state.
  • A generation number for each run. The object increments generation on start and on cancel. After each await, it compares the number. If the number changed, then a user cancelled or restarted the run during the call, and the object discards the result.

Create the agent

1

Configure the binding and vars

Add a Durable Object binding for the Agent class and the model settings to the wrangler.json file:
wrangler.json
Replace MODEL_NAME with the model ID from your provider.Keep the API key in a Secret, not in vars. The callAnthropic function in a later step also reads an optional LLM_BASE_URL var, which lets you point the App at a mock server for tests.
2

Define the schema

The object stores messages and tool calls in two tables:
index.js
The run state lives in ctx.storage.kv under the following keys:
  • status: one of idle, running, waiting_approval, paused, done, failed, or cancelled.
  • generation: the run counter.
  • steps: the number of model calls in this run.
  • attempts: the number of failed attempts for the current unit.
  • error: the reason for a failed or done status, if any.
3

Add the control methods

The control methods change the status and set or delete the alarm. They don’t call the model:
index.js
4

Run one unit in the alarm handler

Each alarm runs one unit. If tool calls are waiting, then the alarm runs them. Otherwise, it calls the model. After a successful unit, the alarm schedules the next one at once:
index.js
The alarm handler does its own retries. It catches model errors, sets the alarm with backoff, and sets the status to failed after MAX_ATTEMPTS. Don’t let the alarm handler throw for expected errors.
5

Call the model and save the response

The callModel method checks the step limit, calls the model, and saves the response and its tool calls in one transaction:
index.js
The callAnthropic function is a module-level function. It sets a timeout on the request:
index.js
A 429 or 5xx response is retryable, and the alarm handler retries it with backoff, obeying the Retry-After header. Any other 4xx response doesn’t improve with a retry, so the function marks it as fatal, and the run fails at once.To show text while the model writes it, call the model with "stream": true, and send each delta to the sockets. Save only the complete message.
6

Run tools and save each result

The runTools method runs the pending tool calls of one model response, saves each result, and then adds the results to the conversation:
index.js
A crash between a tool call and its saved result can cause the tool to run a second time. This method handles both kinds of tool:
  • Idempotent tools run again after a crash. Give each external side effect an idempotency key. The method passes ${sessionId}:${toolCallId} as key.
  • Tools that must run at most one time get the running status, and the method calls await this.ctx.storage.sync() before the side effect. After a restart, the method doesn’t run a running call again. It saves the call as an error and gives that result to the model.
The method truncates each tool output to MAX_TOOL_OUTPUT characters before it saves the output.
7

Add the helper methods

Add the helper methods to the Agent class:
index.js
8

Define the tools

Define each tool twice: as a JSON Schema in TOOLS, which the model receives, and as an implementation in TOOL_IMPL, which the object runs. Also define SYSTEM_PROMPT. The following example has one tool:
index.js
Follow these rules for tools:
  • Validate the input before you use it.
  • For each tool that calls the network, set a timeout with AbortSignal.timeout() and a limit on the response size. Don’t let a URL tool call internal addresses.
  • To run code that the model writes, use a Dynamic Worker with globalOutbound: null. For more information, see Dynamic Workers.
9

Connect clients

Clients send commands through your Worker and receive events over a WebSocket. The Worker routes each request to the object of the session:
index.js
In the Agent class, accept WebSockets with this.ctx.acceptWebSocket, and send the status and the history on connect. A plain HTTP GET returns the same snapshot:
index.js
The object sends each saved event to all sockets through the broadcast method. A socket closes when the object moves to a different node, for example at a deploy. The client must reconnect and load the history again after a close.
10

Optional: Require approval for a tool

For a tool that needs approval, save its tool call with the waiting_approval status, set the run status to waiting_approval, and don’t set the alarm. Add an approve(toolCallId) RPC method that sets the tool call to pending, sets the run status to running, and sets the alarm.

Keep the history within the model context

The history grows at each step. Before each model call, check that the history fits the context of the model. Use one of the following approaches:
  • Send the first user message and the last messages, up to a fixed count.
  • Keep a summary row, and send the summary in place of old messages.
Don’t remove a tool_use block without its tool_result block, because the API refuses the request. Also limit the number of steps, the tokens, and the size of each tool result. The example sets MAX_STEPS, max_tokens, and MAX_TOOL_OUTPUT.

Test the loop

Test the loop on the dev server with a mock model server instead of the real provider:
  1. Write a local mock server for the /v1/messages path. Return a tool_use response first, and a text response second.
  2. In the vars key of the wrangler.json file, set LLM_BASE_URL to the URL of the mock server. Remove the var before you deploy.
  3. Start a run, restart the dev server in the middle of the run, and check that the loop continues.
  4. Stop the dev server with kill -9 during a tool call, send a preview request to start it again, and check that the tool doesn’t run a second time.
  5. Check that the next step starts less than one second after the previous step ends.
  6. Cancel a run during a model call, and check that the object discards the result.
For more information, see Test on the dev server.