Skip to content
bits
Ship an agent to the edge · Lesson 3 of 8

One agent per user

Tutor:

Claude's API is stateless. It remembers nothing between calls; your code sends the whole conversation every time. On your laptop, the history was an array in memory. On Workers, that breaks.

A Worker can run in many places at once, and one copy serves many people. An array at the top of the file would be shared between users, and it would vanish whenever Cloudflare recycles the copy. Cloudflare's own best-practice list flags module-level request state as a bug for exactly this reason.

Memory needs a home that is one per user, stays put, and keeps its data. That's a Durable Object: an instance with a name, its own SQLite database, and the guarantee that a given name always reaches the same instance. Cloudflare's Agents SDK wraps one in an Agent class with this.sql built in.

import { Agent } from "agents";
import Anthropic from "@anthropic-ai/sdk";
type Row = { role: "user" | "assistant"; content: string };
export class ChatAgent extends Agent<Env> {
async onStart() {
this.sql`CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
role TEXT NOT NULL,
content TEXT NOT NULL
)`;
}
async ask(question: string): Promise<{ answer: string }> {
this.save({ role: "user", content: question });
const client = new Anthropic({ apiKey: this.env.ANTHROPIC_API_KEY });
const res = await client.messages.create({
model: "claude-haiku-5-5",
max_tokens: 1024,
output_config: { effort: "low" },
messages: this.history(),
});
// Store the whole content, unchanged. Never edit old turns.
this.save({ role: "assistant", content: res.content });
const answer = res.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
return { answer };
}
reset() {
this.sql`DELETE FROM messages`; // a fresh conversation
}
private history(): Anthropic.MessageParam[] {
return this.sql<Row>`SELECT role, content FROM messages ORDER BY id`
.map((row) => ({ role: row.role, content: JSON.parse(row.content) }));
}
private save(message: Anthropic.MessageParam) {
this.sql`INSERT INTO messages (role, content)
VALUES (${message.role}, ${JSON.stringify(message.content)})`;
}
}
TypeScriptUTF-8● lines 15–32: remember, then answer
Tutor:

That's the agent. The Worker in front of it decides which agent a request reaches, and the config declares the class:

import { getAgentByName } from "agents";
import { currentUser } from "./auth"; // your sign-in
export { ChatAgent } from "./agent";
export default {
async fetch(request, env): Promise<Response> {
const userId = await currentUser(request, env);
if (!userId) return new Response("Sign in first", { status: 401 });
const agent = await getAgentByName(env.ChatAgent, userId);
const { question } = await request.json<{ question: string }>();
return Response.json(await agent.ask(question));
},
} satisfies ExportedHandler<Env>;
TypeScriptUTF-8● lines 10–12: pick the user's agent
Tutor:

Read it from the bottom up. The config declares a Durable Object class with SQLite storage; the Agents SDK needs nodejs_compat. The Worker works out who is asking, then getAgentByName(env.ChatAgent, userId) returns that user's agent and calls its ask() method directly. Each instance keeps its own messages table, so user A's history can't reach user B's prompt.

Two habits in ask() matter later. It stores the assistant's whole content, not just the text: on current Claude models that can include thinking blocks, which must go back unchanged. And it only ever appends. To start over, clear the table and begin a new conversation instead of trimming old turns.

quick check+20 neurons
Tutor:

Where should the agent's instance name (here, userId) come from?

    Type your answer or tap an option · Enter checks

    Tutor:

    The SDK also has routeAgentRequest(), which maps URLs like /agents/chat-agent/:name straight to instances. It's handy, but then the URL picks the instance, so you must check the caller in its onBeforeRequest hook. Choosing the name yourself, as above, keeps that decision in one place.

    quick check+20 neurons
    Tutor:

    The Agent class also has setState(), which syncs a small object to every connected client. Why keep the chat history in this.sql instead?

      Type your answer or tap an option · Enter checks