writing/tutorial/2026/06
TutorialJun 5, 2026·30 min read

Build Stateful AI Agents with the Cloudflare Agents SDK and Durable Objects

Learn how to build persistent, stateful AI agents on Cloudflare with the Agents SDK and Durable Objects. This tutorial covers agent state, SQLite persistence, live React sync with useAgent, calling AI models, scheduled tasks, and server-side RPC — all deployed to the edge.

Most AI agent frameworks have a memory problem. The moment a request finishes, the agent forgets everything — its conversation, its task list, its half-completed work. To make it persistent, you bolt on a database, a queue, a WebSocket server, and a cron scheduler, then spend days gluing them together.

The Cloudflare Agents SDK takes a different path. Each agent is a Durable Object: a tiny, single-threaded compute instance with its own embedded SQLite database that lives at the edge, close to your users. State persists automatically between requests. The same instance can hold a WebSocket connection open, run scheduled tasks, call AI models, and stream updates to a React frontend — all without you provisioning a single server.

In this tutorial you will build a persistent research-assistant agent. Each user gets their own agent instance that remembers their saved notes, answers questions with an AI model, logs every interaction to SQLite, syncs its state live to a React UI, and runs a scheduled daily digest. By the end you will understand the full stateful-agent model and how to deploy it to Cloudflare's network.

Prerequisites

Before starting, make sure you have:

  • Node.js 20+ installed
  • A free Cloudflare account — sign up at dash.cloudflare.com
  • Basic knowledge of TypeScript and React
  • Familiarity with the idea of serverless functions (you do not need prior Workers experience)
  • A code editor (VS Code recommended)

What You'll Build

A research-assistant agent with:

  • Per-user agent instances — each named instance is an isolated Durable Object
  • Persistent state that survives restarts, synced automatically to clients
  • SQLite logging of every question and answer using the embedded this.sql database
  • AI model calls with Workers AI (swappable for OpenAI or any provider)
  • A live React dashboard wired up with the useAgent hook
  • A scheduled daily digest that runs on a cron expression
  • Server-side RPC to drive agents from regular API routes

Let's get started.

Step 1: Scaffold the Project

The fastest way to start is the official starter template, but to understand every moving part we will build from a clean Workers project.

npm create cloudflare@latest research-agent -- --type hello-world --ts
cd research-agent

Choose no for "Do you want to deploy?" when prompted. Now install the Agents SDK:

npm install agents

The agents package gives you the Agent base class, the routing helpers, and the React client. That single dependency is all the runtime needs.

Step 2: Understand the Architecture

Before writing code, it helps to understand the model that makes this different from a normal serverless function.

  • One instance per name. When you address an agent by a name — say user-42 — Cloudflare routes every request for that name to the same Durable Object instance, anywhere in the world. That instance is your agent.
  • State is durable. Each instance has private storage backed by SQLite. Whatever you write with this.setState() or this.sql is still there on the next request, next day, or next deploy.
  • It is single-threaded. Within one instance, requests are processed one at a time. You get strong consistency for free — no race conditions on your agent's state.
  • It wakes and hibernates. When idle, the instance hibernates to save resources and wakes instantly when a new request or scheduled task arrives.

This is what lets an agent feel like a long-lived, stateful object rather than a stateless function.

Step 3: Define Your First Agent

Create src/index.ts and define an agent that holds state. Every agent extends the Agent base class with two type parameters: the environment bindings and the shape of its state.

import { Agent, routeAgentRequest } from "agents";
 
// The shape of one saved note
type Note = {
  id: string;
  text: string;
  createdAt: number;
};
 
// The agent's persistent state
type ResearchState = {
  notes: Note[];
  questionsAsked: number;
};
 
export class ResearchAgent extends Agent<Env, ResearchState> {
  // Runs once when state has never been set
  initialState: ResearchState = {
    notes: [],
    questionsAsked: 0
  };
 
  // An RPC method clients and the server can call
  addNote(text: string) {
    const note: Note = {
      id: crypto.randomUUID(),
      text,
      createdAt: Date.now()
    };
    // setState persists AND broadcasts to connected clients
    this.setState({
      ...this.state,
      notes: [...this.state.notes, note]
    });
    return note;
  }
 
  deleteNote(id: string) {
    this.setState({
      ...this.state,
      notes: this.state.notes.filter((n) => n.id !== id)
    });
  }
}

Two things to notice. First, initialState runs only once per instance — after that, this.state is whatever you last saved. Second, setState() does two jobs at once: it persists the new state to storage and pushes it to every connected client in real time. You never write subscription code.

Step 4: Configure Wrangler

Cloudflare needs to know that ResearchAgent is a Durable Object. Open wrangler.jsonc and add the bindings, a migration, and the Workers AI binding we will use later.

{
  "name": "research-agent",
  "main": "src/index.ts",
  "compatibility_date": "2026-06-01",
  "compatibility_flags": ["nodejs_compat"],
  "durable_objects": {
    "bindings": [
      { "name": "ResearchAgent", "class_name": "ResearchAgent" }
    ]
  },
  "migrations": [
    {
      "tag": "v1",
      "new_sqlite_classes": ["ResearchAgent"]
    }
  ],
  "ai": {
    "binding": "AI"
  }
}

The migrations block is the part newcomers forget. Each agent class must be listed under new_sqlite_classes, or you will hit a No such Durable Object class error at runtime. The ai binding gives the agent access to Workers AI models.

Step 5: Add the Worker Entrypoint

A Worker needs a default export with a fetch handler. The routeAgentRequest helper inspects the incoming URL and routes it to the right agent instance automatically. Add this to the bottom of src/index.ts:

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // Routes /agents/:agent/:name to the matching instance
    const response = await routeAgentRequest(request, env);
    return response ?? new Response("Not found", { status: 404 });
  }
} satisfies ExportedHandler<Env>;

With this, requests to /agents/research-agent/user-42 are dispatched to the ResearchAgent instance named user-42. The naming is automatic: the class ResearchAgent becomes the kebab-case path research-agent.

Run the dev server to confirm everything wires up:

npx wrangler dev

You should see a local server start with no migration errors. Leave it running.

Step 6: Persist Structured Data with SQLite

setState is perfect for the agent's working state, but for an append-only history — every question ever asked — you want a real table. Every agent instance has its own SQLite database exposed through the this.sql tagged template. Add a lifecycle hook and a logging method to the agent:

export class ResearchAgent extends Agent<Env, ResearchState> {
  initialState: ResearchState = { notes: [], questionsAsked: 0 };
 
  // onStart runs when the instance boots or wakes from hibernation
  async onStart() {
    this.sql`
      CREATE TABLE IF NOT EXISTS history (
        id TEXT PRIMARY KEY,
        question TEXT NOT NULL,
        answer TEXT NOT NULL,
        created_at INTEGER NOT NULL
      )
    `;
  }
 
  private logInteraction(question: string, answer: string) {
    this.sql`
      INSERT INTO history (id, question, answer, created_at)
      VALUES (
        ${crypto.randomUUID()},
        ${question},
        ${answer},
        ${Date.now()}
      )
    `;
  }
 
  getHistory() {
    return this.sql`
      SELECT * FROM history
      ORDER BY created_at DESC
      LIMIT 50
    `;
  }
}

The this.sql template safely interpolates values as bound parameters, so there is no SQL injection risk. Because the table lives inside the Durable Object, queries are local and fast — there is no network hop to a remote database.

Tip: Use setState for small reactive state that the UI renders directly, and this.sql for larger or append-only datasets you query on demand. Mixing both is the idiomatic pattern.

Step 7: Call an AI Model Inside the Agent

Now make the agent actually do research. Add an ask method that calls a Workers AI model, updates the counter in state, and logs the exchange to SQLite.

async ask(question: string): Promise<string> {
  // Call a Workers AI model through the env binding
  const result = await this.env.AI.run("@cf/meta/llama-3.3-70b-instruct", {
    messages: [
      {
        role: "system",
        content: "You are a concise research assistant. Answer in 3 sentences."
      },
      { role: "user", content: question }
    ]
  });
 
  const answer = result.response ?? "No answer generated.";
 
  // Update reactive state — clients see the new count instantly
  this.setState({
    ...this.state,
    questionsAsked: this.state.questionsAsked + 1
  });
 
  // Persist the full exchange to the agent's history table
  this.logInteraction(question, answer);
 
  return answer;
}

Because this runs inside the agent, the model call, the state update, and the SQLite write all share the same consistent instance. If you prefer OpenAI, Anthropic, or another provider, swap the this.env.AI.run call for that provider's SDK — the rest of the method is unchanged.

Step 8: Build a Live React Dashboard

Here is where the model pays off. The useAgent hook from agents/react opens a WebSocket to a named agent instance and keeps state in sync automatically. When the agent calls setState, your component re-renders — no polling, no manual fetches.

Create a React component (this can live in a separate frontend app or a Workers static asset):

import { useAgent } from "agents/react";
import { useState } from "react";
 
type Note = { id: string; text: string; createdAt: number };
type ResearchState = { notes: Note[]; questionsAsked: number };
 
export function ResearchDashboard({ userId }: { userId: string }) {
  const [draft, setDraft] = useState("");
  const [question, setQuestion] = useState("");
  const [answer, setAnswer] = useState("");
 
  // Connect to this user's own agent instance
  const agent = useAgent<ResearchState>({
    agent: "research-agent",
    name: userId
  });
 
  async function handleAsk() {
    // Call the agent's RPC method over the open connection
    const reply = await agent.stub.ask(question);
    setAnswer(reply);
    setQuestion("");
  }
 
  return (
    <div>
      <h2>Questions asked: {agent.state?.questionsAsked ?? 0}</h2>
 
      <input
        value={draft}
        onChange={(e) => setDraft(e.target.value)}
        placeholder="Save a note"
      />
      <button onClick={() => { agent.stub.addNote(draft); setDraft(""); }}>
        Add note
      </button>
 
      <ul>
        {(agent.state?.notes ?? []).map((n) => (
          <li key={n.id}>
            {n.text}
            <button onClick={() => agent.stub.deleteNote(n.id)}>x</button>
          </li>
        ))}
      </ul>
 
      <input
        value={question}
        onChange={(e) => setQuestion(e.target.value)}
        placeholder="Ask a research question"
      />
      <button onClick={handleAsk}>Ask</button>
      {answer && <p>{answer}</p>}
    </div>
  );
}

Two clients connected to the same userId will see each other's notes appear instantly, because setState broadcasts to every connection. The agent.stub object is a typed proxy: calling agent.stub.ask(...) invokes the ask method on the server and returns its result.

Step 9: Schedule Recurring Tasks

A standout feature: agents can schedule their own future work. No external cron service is needed — the scheduler lives inside the instance. Use this.schedule(when, methodName, payload). The when argument accepts a delay in seconds, a Date, or a cron expression.

async onStart() {
  this.sql`CREATE TABLE IF NOT EXISTS history (
    id TEXT PRIMARY KEY, question TEXT, answer TEXT, created_at INTEGER
  )`;
 
  // Run a digest every day at 08:00 (only schedules once per instance)
  await this.schedule("0 8 * * *", "dailyDigest", {});
}
 
async dailyDigest() {
  const rows = this.getHistory();
  const count = this.state.questionsAsked;
  console.log(`Daily digest: ${count} questions, ${rows.length} logged.`);
  // Here you could email a summary, push a notification,
  // or call the AI model to summarise the day's research.
}

When the scheduled time arrives, Cloudflare wakes the hibernating instance and calls dailyDigest — even if no user is connected. This makes background work, reminders, and follow-ups trivial. You can also schedule one-off tasks with await this.schedule(30, "methodName", payload) to run in 30 seconds.

Step 10: Drive Agents from the Server with RPC

Sometimes you need to interact with an agent from a regular API route — a webhook, a cron Worker, or another service. Use getAgentByName to grab a typed stub of any instance and call its methods directly.

import { getAgentByName } from "agents";
 
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
 
    // A plain API route that talks to an agent server-side
    if (url.pathname === "/api/ask") {
      const { userId, question } = await request.json<{
        userId: string;
        question: string;
      }>();
 
      const agent = await getAgentByName(env.ResearchAgent, userId);
      const answer = await agent.ask(question);
      return Response.json({ answer });
    }
 
    const response = await routeAgentRequest(request, env);
    return response ?? new Response("Not found", { status: 404 });
  }
} satisfies ExportedHandler<Env>;

getAgentByName(env.ResearchAgent, userId) returns the same instance the React dashboard is connected to. Call ask from the server and the result — the new question count — instantly reflects in the user's open UI, because state is shared across every entry point.

Testing Your Implementation

Start the dev server and exercise the agent:

npx wrangler dev

Then send a request to the server-side route:

curl -X POST http://localhost:8787/api/ask \
  -H "Content-Type: application/json" \
  -d '{"userId":"user-42","question":"What is edge computing?"}'

You should get a JSON answer back. Run it again and the agent's questionsAsked counter increments — proof the state persisted between two independent requests to the same instance.

Deploying to Cloudflare

Deployment is a single command. Wrangler bundles your Worker, provisions the Durable Objects, and pushes everything to Cloudflare's global network:

npx wrangler deploy

Your agent now runs in hundreds of locations worldwide, each instance materialising close to the user who addresses it. There are no servers to manage and no database to provision — the SQLite storage travels with each Durable Object.

Troubleshooting

No such Durable Object class error. Your agent class is missing from the new_sqlite_classes list in wrangler.jsonc. Add it under a migrations entry and restart.

State is undefined on first render. agent.state is undefined until the WebSocket connects and the first sync arrives. Always read it defensively, for example agent.state?.notes ?? [].

Scheduled task never fires. Cron expressions run in UTC. Double-check the expression and confirm the agent instance has been created at least once — scheduling happens inside onStart, which only runs when an instance boots.

AI binding is undefined. Make sure the ai binding block exists in wrangler.jsonc and that you redeployed after adding it.

Next Steps

  • Add human-in-the-loop approval by pausing in a tool call and resuming when the user confirms.
  • Stream AI responses token by token to the UI instead of returning a single string.
  • Explore the SDK's chat-agent layer for full conversation management with message history.
  • Combine agents with Cloudflare Workflows for durable multi-step pipelines.
  • Read our companion guide on secure code execution for AI agents when your agent needs to run untrusted code.

Conclusion

The Cloudflare Agents SDK collapses the usual stack — database, queue, WebSocket server, scheduler — into a single primitive: a stateful, durable, edge-resident object. You wrote one Agent class and got persistent state, live React sync, SQLite logging, AI model calls, scheduled work, and server-side RPC, then deployed it with one command.

The mental shift is treating each agent as a long-lived object addressed by name rather than a stateless function. Once that clicks, building persistent, multiplayer, AI-powered experiences becomes dramatically simpler — and it all runs at the edge, milliseconds from your users.