Stateful Agents with Durable Objects
Learn how to build scalable, multi-tenant AI agent systems using Cloudflare Durable Objects—the secret to maintaining state, eliminating race conditions, and achieving true per-user isolation.
Time to complete : 45-60 minutesWhat you’ll build : A multi-tenant MCP server where each user gets their own stateful agent instance with isolated wallet and data
Why Durable Objects?
Traditional serverless functions (AWS Lambda, Cloudflare Workers) are stateless . Every request is independent, making them perfect for simple APIs but terrible for agents that need:
Persistent connections (WebSockets, Server-Sent Events)
Conversation history across multiple requests
Payment state tracking (pending, confirmed, settled)
Per-user isolation without database queries
Regular Workers ❌ Lost state between requests ❌ Race conditions with concurrent requests ❌ No WebSocket support ❌ Shared instances across users
Durable Objects ✅ In-memory state persists ✅ Single-threaded (no races) ✅ Built-in WebSocket support ✅ Unique instance per user ID
Core Concepts
What is a Durable Object?
A Durable Object is a stateful mini-server that:
Lives in one datacenter (close to users for low latency)
Processes requests serially (no concurrency bugs)
Maintains in-memory state (across requests)
Hibernates when idle (no cost when not in use)
Is globally unique by ID (same ID → same instance worldwide)
// Traditional Worker (stateless)
export default {
async fetch ( request : Request ) {
// No state between requests!
return new Response ( "Hello" );
}
} ;
// Durable Object (stateful)
export class Agent extends DurableObject {
private conversationHistory : Message [] = [];
private wallet : Wallet ;
constructor ( state : DurableObjectState , env : Env ) {
super ( state , env );
// Initialize ONCE, persists across requests
this . wallet = createWallet ();
}
async fetch ( request : Request ) {
// State persists!
this . conversationHistory . push ( ... );
return new Response ( "Hello from " + this . wallet . address );
}
}
Per-User Isolation
Each user gets their own DO instance:
// Gateway Worker routes to user-specific DO
export default {
async fetch ( request : Request , env : Env ) {
const userId = getUserId ( request );
// Get or create DO for this user
const doId = env . AGENT . idFromName ( userId );
const agentDO = env . AGENT . get ( doId );
// All requests for this user hit THE SAME instance
return agentDO . fetch ( request );
}
} ;
Result:
User A → /agent/alice → Agent DO (name: "alice")
├─ wallet: 0xAAA...
├─ conversations: [...]
└─ budget: $50
User B → /agent/bob → Agent DO (name: "bob")
├─ wallet: 0xBBB...
├─ conversations: [...]
└─ budget: $100
Prerequisites
Cloudflare Account Free tier works for development
Wrangler CLI npm install -g wrangler
Crossmint API Key For wallet creation
Node.js 18+ TypeScript environment
Step 1: Project Setup
npm create cloudflare@latest my-agent-do
# Select "Hello World" template
# Choose TypeScript
cd my-agent-do
npm install @crossmint/wallets-sdk agents x402
Update wrangler.toml:
name = "my-agent-do"
main = "src/index.ts"
compatibility_date = "2024-03-01"
# Define Durable Object bindings
[[ durable_objects . bindings ]]
name = "AGENT"
class_name = "Agent"
script_name = "my-agent-do"
# KV for persistent storage
[[ kv_namespaces ]]
binding = "DATA"
id = "<your-kv-namespace-id>"
Create KV namespace:
npx wrangler kv:namespace create "DATA"
# Copy the ID to wrangler.toml
Step 2: Define the Durable Object
Create the Agent Durable Object:
import { DurableObject } from "cloudflare:workers" ;
import { CrossmintWallets , createCrossmint , type Wallet } from "@crossmint/wallets-sdk" ;
export interface Env {
CROSSMINT_API_KEY : string ;
DATA : KVNamespace ;
}
interface Message {
role : "user" | "assistant" ;
content : string ;
timestamp : number ;
}
export class Agent extends DurableObject < Env > {
private wallet !: Wallet < any >;
private messages : Message [] = [];
private userId : string ;
private websockets : Set < WebSocket > = new Set ();
constructor ( state : DurableObjectState , env : Env ) {
super ( state , env );
// Extract user ID from DO name
this . userId = state . id . toString ();
console . log ( `📦 Agent DO created for user: ${ this . userId } ` );
}
/**
* Initialize wallet on first request
*/
private async ensureWallet () : Promise < void > {
if ( this . wallet ) return ;
console . log ( `🔧 Creating wallet for ${ this . userId } ...` );
const crossmint = createCrossmint ({
apiKey: this . env . CROSSMINT_API_KEY
});
const crossmintWallets = CrossmintWallets . from ( crossmint );
this . wallet = await crossmintWallets . createWallet ({
chain: "base-sepolia" ,
signer: { type: "api-key" },
owner: `user- ${ this . userId } `
});
console . log ( `✅ Wallet ready: ${ this . wallet . address } ` );
}
/**
* Load conversation history from KV
*/
private async loadHistory () : Promise < void > {
const stored = await this . env . DATA . get ( ` ${ this . userId } :messages` , "json" );
if ( stored ) {
this . messages = stored as Message [];
console . log ( `📚 Loaded ${ this . messages . length } messages from KV` );
}
}
/**
* Save conversation history to KV
*/
private async saveHistory () : Promise < void > {
await this . env . DATA . put (
` ${ this . userId } :messages` ,
JSON . stringify ( this . messages )
);
}
/**
* Broadcast message to all connected WebSockets
*/
private broadcast ( message : string ) : void {
for ( const ws of this . websockets ) {
try {
ws . send ( message );
} catch ( error ) {
console . error ( "Failed to send to WebSocket:" , error );
this . websockets . delete ( ws );
}
}
}
/**
* Handle incoming HTTP requests
*/
async fetch ( request : Request ) : Promise < Response > {
await this . ensureWallet ();
const url = new URL ( request . url );
// WebSocket upgrade
if ( request . headers . get ( "Upgrade" ) === "websocket" ) {
return this . handleWebSocket ( request );
}
// GET /info - Return agent info
if ( url . pathname === "/info" && request . method === "GET" ) {
return Response . json ({
userId: this . userId ,
walletAddress: this . wallet . address ,
messageCount: this . messages . length
});
}
// POST /message - Send message to agent
if ( url . pathname === "/message" && request . method === "POST" ) {
const { content } = await request . json () as { content : string };
// Add user message
const userMessage : Message = {
role: "user" ,
content ,
timestamp: Date . now ()
};
this . messages . push ( userMessage );
// Generate agent response (simplified)
const response = await this . generateResponse ( content );
const agentMessage : Message = {
role: "assistant" ,
content: response ,
timestamp: Date . now ()
};
this . messages . push ( agentMessage );
// Save to KV
await this . saveHistory ();
// Broadcast to WebSocket clients
this . broadcast ( JSON . stringify ( agentMessage ));
return Response . json ({
response ,
messageCount: this . messages . length
});
}
// GET /history - Get conversation history
if ( url . pathname === "/history" && request . method === "GET" ) {
await this . loadHistory ();
return Response . json ({
messages: this . messages
});
}
return new Response ( "Not Found" , { status: 404 });
}
/**
* Handle WebSocket connections
*/
private handleWebSocket ( request : Request ) : Response {
const pair = new WebSocketPair ();
const [ client , server ] = Object . values ( pair );
// Accept the connection
server . accept ();
this . websockets . add ( server );
console . log ( `🔌 WebSocket connected (total: ${ this . websockets . size } )` );
// Send welcome message
server . send ( JSON . stringify ({
type: "welcome" ,
userId: this . userId ,
walletAddress: this . wallet . address ,
messageCount: this . messages . length
}));
// Handle incoming messages
server . addEventListener ( "message" , async ( event ) => {
try {
const data = JSON . parse ( event . data as string );
if ( data . type === "message" ) {
// Process message
const response = await this . generateResponse ( data . content );
// Send response
server . send ( JSON . stringify ({
type: "message" ,
role: "assistant" ,
content: response ,
timestamp: Date . now ()
}));
}
} catch ( error ) {
console . error ( "WebSocket message error:" , error );
}
});
// Handle disconnection
server . addEventListener ( "close" , () => {
this . websockets . delete ( server );
console . log ( `🔌 WebSocket disconnected (total: ${ this . websockets . size } )` );
});
return new Response ( null , {
status: 101 ,
webSocket: client
});
}
/**
* Generate agent response (integrate with Claude, GPT, etc.)
*/
private async generateResponse ( userMessage : string ) : Promise < string > {
// This is where you'd call Claude, GPT, or your LLM
// For now, simple echo with context
const context = this . messages . length > 0
? `I remember our conversation ( ${ this . messages . length } messages).`
: "This is our first message." ;
return ` ${ context } You said: " ${ userMessage } ". My wallet is ${ this . wallet . address . slice ( 0 , 10 ) } ...` ;
}
}
Step 3: Gateway Worker
Create the routing layer:
import { Agent , type Env } from "./agent" ;
export { Agent };
export default {
async fetch ( request : Request , env : Env , ctx : ExecutionContext ) : Promise < Response > {
const url = new URL ( request . url );
// Extract user ID from path: /agent/{userId}
const pathMatch = url . pathname . match ( / ^ \/ agent \/ ( [ ^ \/ ] + ) / );
if ( ! pathMatch ) {
return new Response ( "Usage: /agent/{userId}" , { status: 400 });
}
const userId = pathMatch [ 1 ];
// Get Durable Object for this user
const doId = env . AGENT . idFromName ( userId );
const agentDO = env . AGENT . get ( doId );
// Remove /agent/{userId} prefix from path
const newUrl = new URL ( request . url );
newUrl . pathname = url . pathname . replace ( `/agent/ ${ userId } ` , "" ) || "/" ;
const newRequest = new Request ( newUrl , request );
// Forward to Durable Object
return agentDO . fetch ( newRequest );
}
} satisfies ExportedHandler < Env > ;
Step 4: Deploy and Test
Set Secrets
npx wrangler secret put CROSSMINT_API_KEY
# Paste your API key when prompted
Deploy
You’ll get a URL like: https://my-agent-do.your-subdomain.workers.dev
Test with curl
Get Agent Info
Send Message
Get History
curl https://my-agent-do.your-subdomain.workers.dev/agent/alice/info
# Response:
{
"userId" : "alice",
"walletAddress" : "0x1234...",
"messageCount" : 0
}
Test with WebSocket
const ws = new WebSocket ( "wss://my-agent-do.your-subdomain.workers.dev/agent/alice" );
ws . onmessage = ( event ) => {
console . log ( "Received:" , JSON . parse ( event . data ));
};
ws . onopen = () => {
ws . send ( JSON . stringify ({
type: "message" ,
content: "Hello via WebSocket!"
}));
};
Step 5: Add Payment Support
Integrate x402 payments:
import { withX402 } from "agents/x402" ;
import { createX402Signer } from "./x402Adapter" ;
export class Agent extends DurableObject < Env > {
private mcpServer !: McpServer ;
async ensureWallet () : Promise < void > {
if ( this . wallet ) return ;
// ... create wallet ...
// Create MCP server with x402 support
this . mcpServer = new McpServer ({
name: `Agent- ${ this . userId } ` ,
version: "1.0.0"
});
// Add x402 payment middleware
this . mcpServer . withX402 ({
wallet: createX402Signer ( this . wallet ),
facilitator: "https://x402.org/facilitator" ,
network: "base-sepolia" ,
usdcAddress: "0x036CbD53842c5426634e7929541eC2318f3dCF7e"
});
// Register paid tools
this . mcpServer . paidTool (
"premium_analysis" ,
"Advanced analysis with AI" ,
0.10 , // $0.10 per call
{ query: z . string () },
{},
async ({ query }) => {
// Only called after payment verified!
return await this . runPremiumAnalysis ( query );
}
);
}
async fetch ( request : Request ) : Promise < Response > {
await this . ensureWallet ();
// Handle MCP requests
if ( url . pathname === "/mcp" ) {
return this . mcpServer . handleRequest ( request );
}
// ... rest of handlers ...
}
}
Advanced Patterns
1. Alarm-Based Persistence
Auto-save state periodically:
export class Agent extends DurableObject < Env > {
constructor ( state : DurableObjectState , env : Env ) {
super ( state , env );
// Set alarm to save state every 5 minutes
this . ctx . storage . setAlarm ( Date . now () + 5 * 60 * 1000 );
}
async alarm () : Promise < void > {
console . log ( "⏰ Alarm triggered, saving state..." );
// Save to KV
await this . saveHistory ();
// Schedule next alarm
await this . ctx . storage . setAlarm ( Date . now () + 5 * 60 * 1000 );
}
}
2. Hibernatable WebSockets
Reduce costs with hibernation:
export class Agent extends DurableObject < Env > {
async webSocketMessage ( ws : WebSocket , message : string ) : Promise < void > {
// Called when WebSocket receives message
const data = JSON . parse ( message );
// Process and respond
const response = await this . generateResponse ( data . content );
ws . send ( JSON . stringify ({ response }));
}
async webSocketClose ( ws : WebSocket , code : number , reason : string ) : Promise < void > {
console . log ( `WebSocket closed: ${ code } ${ reason } ` );
this . websockets . delete ( ws );
}
}
3. Cross-DO Communication
Agents can talk to each other:
export class Agent extends DurableObject < Env > {
async sendToAgent ( targetUserId : string , message : string ) : Promise < void > {
// Get target agent's DO
const targetId = this . env . AGENT . idFromName ( targetUserId );
const targetDO = this . env . AGENT . get ( targetId );
// Send message
await targetDO . fetch ( new Request ( "http://internal/receive" , {
method: "POST" ,
body: JSON . stringify ({ from: this . userId , message })
}));
}
}
Production Considerations
Durable Objects keep state in memory, but:
Use KV for critical data (messages, transactions)
Set alarms to periodically flush to KV
Handle hibernation - objects can be evicted when idle
Test recovery - ensure state rebuilds correctly
Global distribution : DOs created near users automatically
No limits on DOs : Create millions of instances
Cost : 0.15 p e r m i l l i o n r e q u e s t s + 0.15 per million requests + 0.15 p er mi ll i o n re q u es t s + 0.02 per GB-second
Hibernation : Idle DOs cost $0 until woken
export class Agent extends DurableObject < Env > {
async fetch ( request : Request ) : Promise < Response > {
const start = Date . now ();
try {
const response = await this . handleRequest ( request );
// Log successful request
console . log ({
userId: this . userId ,
path: new URL ( request . url ). pathname ,
duration: Date . now () - start ,
status: response . status
});
return response ;
} catch ( error ) {
// Log error
console . error ({
userId: this . userId ,
error: error instanceof Error ? error . message : String ( error )
});
throw error ;
}
}
}
Common Patterns
Session Management
interface Session {
id : string ;
createdAt : number ;
lastActive : number ;
metadata : Record < string , any >;
}
export class Agent extends DurableObject < Env > {
private sessions : Map < string , Session > = new Map ();
createSession ( metadata : Record < string , any >) : string {
const sessionId = crypto . randomUUID ();
this . sessions . set ( sessionId , {
id: sessionId ,
createdAt: Date . now (),
lastActive: Date . now (),
metadata
});
return sessionId ;
}
getSession ( sessionId : string ) : Session | undefined {
const session = this . sessions . get ( sessionId );
if ( session ) {
session . lastActive = Date . now ();
}
return session ;
}
}
Rate Limiting
interface RateLimit {
requests : number [];
limit : number ;
window : number ;
}
export class Agent extends DurableObject < Env > {
private rateLimits = new Map < string , RateLimit >();
checkRateLimit ( key : string , limit = 10 , window = 60000 ) : boolean {
const now = Date . now ();
const limiter = this . rateLimits . get ( key ) || {
requests: [],
limit ,
window
};
// Remove old requests
limiter . requests = limiter . requests . filter ( t => now - t < window );
// Check limit
if ( limiter . requests . length >= limit ) {
return false ;
}
// Add request
limiter . requests . push ( now );
this . rateLimits . set ( key , limiter );
return true ;
}
}
Next Steps
Event RSVP See a production DO-based agent system
Cloudflare Agents Agent-to-agent payments on the edge
DO Docs Official Cloudflare documentation
Workers SDK Cloudflare Workers platform docs
Troubleshooting
DO not found / 404 errors
Verify DO is exported in src/index.ts: export { Agent }
Check wrangler.toml has correct binding name
Ensure class_name matches exported class
Try wrangler deploy again
DOs keep state in memory, not disk
Use KV for durable storage: env.DATA.put(...)
Set alarms for periodic saves
Test after simulated eviction
Check hibernation settings
Implement reconnection logic in client
Use heartbeat/ping-pong to keep alive
Monitor close codes for debugging