If you've built anything with Claude API on Cloudflare Workers, you've probably hit this wall: conversation context disappears between requests. Workers are inherently stateless — each request spins up independently, with no shared memory.
The usual workarounds — dumping history to KV, querying D1 on every turn — get messy fast. KV's eventual consistency means you can read stale data right after a write. D1 doesn't give you actor-model exclusivity, so multiple Worker instances can race to update the same session row.
I ran into this across several projects and eventually landed on Cloudflare Durable Objects (DO) as the right abstraction. A DO is a globally unique stateful object that runs on Workers infrastructure — essentially a tiny, named, single-threaded actor. Every request for session abc123 routes to the exact same DO instance, worldwide, with no locking required to safely update its storage.
Pair that with the Hibernation API (so the DO sleeps between WebSocket messages) and Claude's streaming API, and you have a production-grade foundation for stateful AI conversations.
This guide walks through the complete implementation — from the initial setup to compaction, alarm-based cleanup, and scaling pitfalls I've personally hit in production.
Why Durable Objects Fit AI Session Management
Before diving into code, it's worth understanding why DOs outperform the alternatives for this specific use case.
Workers KV
KV uses eventual consistency. "You just wrote a value, read it back, and get stale data" is not a hypothetical — it happens. For AI session history where you're alternating writes and reads every single turn, KV is the wrong tool.
D1 (SQLite)
D1 is transactionally consistent within a single query, but it doesn't give you the actor-model guarantee. Multiple Worker instances processing concurrent requests can race on the same session row. You'd need application-level locking, which is complex and error-prone.
Durable Objects
The core design principle: a DO identified by name routes all requests through a single instance. env.AGENT.idFromName("session-abc123") ensures every request for that session hits the same DO, which processes one request at a time. No locks needed — the actor model handles it.
The Hibernation API takes this further: the DO can hold a live WebSocket connection without burning CPU between messages. It wakes up only when a message arrives. For long-lived AI conversations, this cuts costs dramatically compared to polling.
Architecture Overview
Here's the component map before we get into code:
Browser → Worker Router → Durable Object (AgentSession)
├── DO Storage (conversation history)
├── Claude API (Anthropic)
└── WebSocket (bidirectional with browser)
Component responsibilities:
- Worker Router: Routes incoming requests to the correct DO based on session ID; handles auth
- AgentSession DO: One instance per session. Owns conversation history, Claude API calls, and WebSocket streaming
- DO Storage: Persists message history, session metadata, and compacted summaries across DO hibernation
Durable Object Skeleton
The AgentSession DO uses the Hibernation API. Instead of implementing fetch for WebSocket handling, you implement webSocketMessage and webSocketClose event handlers. This is the critical difference that enables sleeping between messages.
// src/agent-session.ts
import Anthropic from "@anthropic-ai/sdk";
interface Message {
role: "user" | "assistant";
content: string;
timestamp: number;
}
interface SessionState {
messages: Message[];
compactedSummary?: string;
lastActivity: number;
totalTokensUsed: number;
}
export class AgentSession implements DurableObject {
private state: DurableObjectState;
private env: Env;
private client: Anthropic;
private sessionData: SessionState = {
messages: [],
lastActivity: Date.now(),
totalTokensUsed: 0,
};
constructor(state: DurableObjectState, env: Env) {
this.state = state;
this.env = env;
this.client = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });
// Restore session state from storage on initialization
this.state.blockConcurrencyWhile(async () => {
const stored = await this.state.storage.get<SessionState>("session");
if (stored) {
this.sessionData = stored;
}
});
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (request.headers.get("Upgrade") === "websocket") {
return this.handleWebSocket(request);
}
if (url.pathname.endsWith("/history")) {
return new Response(
JSON.stringify(this.sessionData.messages),
{ headers: { "Content-Type": "application/json" } }
);
}
if (url.pathname.endsWith("/reset")) {
await this.resetSession();
return new Response("Session reset", { status: 200 });
}
return new Response("Not found", { status: 404 });
}
private async handleWebSocket(request: Request): Promise<Response> {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
// Hibernation API: acceptWebSocket instead of server.accept()
// This allows the DO to hibernate between messages
this.state.acceptWebSocket(server);
return new Response(null, {
status: 101,
webSocket: client,
});
}
// Hibernation API handler — only called when a message arrives
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
if (typeof message !== "string") return;
let parsed: { type: string; content?: string };
try {
parsed = JSON.parse(message);
} catch {
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
return;
}
if (parsed.type === "message" && parsed.content) {
await this.processUserMessage(ws, parsed.content);
}
}
async webSocketClose(ws: WebSocket, code: number, reason: string): Promise<void> {
this.sessionData.lastActivity = Date.now();
await this.state.storage.put("session", this.sessionData);
}
private async resetSession(): Promise<void> {
this.sessionData = {
messages: [],
lastActivity: Date.now(),
totalTokensUsed: 0,
};
await this.state.storage.put("session", this.sessionData);
}
}Two details worth calling out: blockConcurrencyWhile in the constructor ensures the storage read completes before any request can be processed — without it, you get initialization races. And acceptWebSocket (not server.accept()) is what enables hibernation.
Core Integration: Claude API Streaming Over WebSocket
The heart of the implementation — processing user messages, streaming Claude's response, and persisting the result.
// Add to AgentSession class
private async processUserMessage(ws: WebSocket, userContent: string): Promise<void> {
// Run compaction check first (detailed in next section)
await this.checkAndCompact();
// Add user message to history
const userMessage: Message = {
role: "user",
content: userContent,
timestamp: Date.now(),
};
this.sessionData.messages.push(userMessage);
// Build message array for Claude API (includes compacted summary if present)
const messages = await this.buildMessagesForApi();
ws.send(JSON.stringify({ type: "start" }));
let assistantContent = "";
try {
const stream = await this.client.messages.stream({
model: "claude-sonnet-4-6",
max_tokens: 4096,
system: this.env.SYSTEM_PROMPT || "You are a helpful AI assistant.",
messages,
});
for await (const chunk of stream) {
if (
chunk.type === "content_block_delta" &&
chunk.delta.type === "text_delta"
) {
const text = chunk.delta.text;
assistantContent += text;
// Forward each delta to the browser in real time
ws.send(JSON.stringify({ type: "delta", text }));
}
}
// Get token usage from the final message object
// (usage isn't reliably available mid-stream)
const finalMessage = await stream.getFinalMessage();
this.sessionData.totalTokensUsed +=
finalMessage.usage.input_tokens + finalMessage.usage.output_tokens;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : "Unknown error";
ws.send(JSON.stringify({ type: "error", message: errorMsg }));
// Roll back the user message on error — don't leave an unanswered question in history
this.sessionData.messages.pop();
await this.state.storage.put("session", this.sessionData);
return;
}
// Persist assistant response
const assistantMessage: Message = {
role: "assistant",
content: assistantContent,
timestamp: Date.now(),
};
this.sessionData.messages.push(assistantMessage);
this.sessionData.lastActivity = Date.now();
// Persist to storage immediately after each exchange
await this.state.storage.put("session", this.sessionData);
ws.send(JSON.stringify({ type: "end" }));
}
private buildMessagesForApi(): Anthropic.MessageParam[] {
const apiMessages: Anthropic.MessageParam[] = [];
// If we have a compacted summary, prepend it as synthetic context
if (this.sessionData.compactedSummary) {
apiMessages.push({
role: "user",
content: `[Previous conversation summary]\n${this.sessionData.compactedSummary}`,
});
apiMessages.push({
role: "assistant",
content: "Understood. I'll continue with that context in mind.",
});
}
// Append the actual (potentially trimmed) message history
for (const msg of this.sessionData.messages) {
apiMessages.push({ role: msg.role, content: msg.content });
}
return apiMessages;
}Rolling back the user message on API errors is something many implementations skip. But leaving an unanswered question at the end of the history confuses Claude on subsequent turns — it tries to answer the old question rather than the new one. The rollback keeps history clean.
Context Window Management and Compaction
Long conversations drive token costs exponentially if you pass the full history every turn. This is the most commonly underestimated cost in production AI chat systems.
The approach that's worked best for me: summary-based compaction. When the history exceeds a threshold, summarize everything except the most recent N messages, store the summary, and drop the old messages.
// Add to AgentSession class
private readonly MAX_MESSAGES_BEFORE_COMPACTION = 20;
private readonly KEEP_RECENT_MESSAGES = 6;
async checkAndCompact(): Promise<void> {
if (this.sessionData.messages.length < this.MAX_MESSAGES_BEFORE_COMPACTION) {
return;
}
const toSummarize = this.sessionData.messages.slice(
0,
this.sessionData.messages.length - this.KEEP_RECENT_MESSAGES
);
const recent = this.sessionData.messages.slice(-this.KEEP_RECENT_MESSAGES);
const summaryPrompt = toSummarize
.map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content}`)
.join("\n\n");
try {
const summaryResponse = await this.client.messages.create({
// Use Haiku for summarization — roughly 7x cheaper than Sonnet
// and entirely adequate for extracting key facts from conversation history
model: "claude-haiku-4-5-20251001",
max_tokens: 1024,
messages: [
{
role: "user",
content: `Summarize the following conversation in 200-400 words so that a future AI assistant can understand the full context. Include all important decisions, technical details, and agreed-upon information.\n\n${summaryPrompt}`,
},
],
});
const summary =
summaryResponse.content[0].type === "text"
? summaryResponse.content[0].text
: "";
// Append to existing summary if we've compacted before
this.sessionData.compactedSummary = this.sessionData.compactedSummary
? `${this.sessionData.compactedSummary}\n\n---\n\n${summary}`
: summary;
// Replace full history with just the recent messages
this.sessionData.messages = recent;
await this.state.storage.put("session", this.sessionData);
} catch (error) {
// Compaction failure is non-fatal — the session continues working,
// just with a larger-than-ideal context. Log it and move on.
console.error("Compaction failed:", error);
}
}Using Haiku for summaries is an important cost decision. At production scale, the compaction calls can account for a meaningful percentage of your Claude API spend. Haiku produces summaries that are good enough to preserve conversational context at a fraction of the cost.
Alarm API for Automatic Session Cleanup
DO Storage isn't free at scale. Sessions that go dormant for days or weeks accumulate storage costs. The Alarm API lets you schedule automatic cleanup without any external cron system.
// Add to AgentSession class
private readonly SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
private async resetAlarm(): Promise<void> {
const expireAt = Date.now() + this.SESSION_TTL_MS;
await this.state.storage.setAlarm(expireAt);
}
// Called automatically when the alarm fires
async alarm(): Promise<void> {
const elapsed = Date.now() - this.sessionData.lastActivity;
if (elapsed >= this.SESSION_TTL_MS) {
// Session hasn't been used in 24 hours — clean up
await this.state.storage.deleteAll();
console.log(`Session cleaned up. Last activity: ${new Date(this.sessionData.lastActivity).toISOString()}`);
} else {
// Still active — reschedule the alarm
await this.resetAlarm();
}
}Call resetAlarm() inside webSocketClose (or after each message exchange if you want finer-grained TTLs). The alarm fires at the scheduled time even if the DO has hibernated — it wakes up just to run alarm(), then goes back to sleep.
Worker Router
The routing layer is straightforward:
// src/index.ts
export { AgentSession } from "./agent-session";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const match = url.pathname.match(/^\/agent\/([a-zA-Z0-9_-]+)(\/.*)?$/);
if (!match) {
return new Response("Not found", { status: 404 });
}
const sessionId = match[1];
const authHeader = request.headers.get("Authorization");
if (!authHeader || !isValidToken(authHeader, env)) {
return new Response("Unauthorized", { status: 401 });
}
// Name-based routing — all requests for sessionId go to the same DO instance
const id = env.AGENT.idFromName(sessionId);
const stub = env.AGENT.get(id);
return stub.fetch(request);
},
};
function isValidToken(authHeader: string, env: Env): boolean {
const token = authHeader.replace("Bearer ", "");
return token === env.API_SECRET;
}In production, replace the simple token comparison with JWT validation. You also want to verify that the authenticated user owns the session ID they're requesting — otherwise, any authenticated user could access any session.
Common Pitfalls
Three issues I've hit in production that are worth knowing upfront.
Pitfall 1: Forgetting blockConcurrencyWhile causes initialization races
If you read from Storage in the constructor without blockConcurrencyWhile, requests can arrive before the storage read completes. They'll process against empty session state, and the first write will overwrite whatever the storage read eventually returns. Always use blockConcurrencyWhile for initialization reads.
Pitfall 2: WebSocket abrupt disconnection doesn't call webSocketClose
Network drops, browser tab closes, and mobile background events can all close the WebSocket without triggering webSocketClose. Design your persistence strategy around message completion, not connection close events. Persist to storage right after each Claude API response, not when the connection closes.
Pitfall 3: Compaction blocks message processing
DOs are single-threaded per instance. While compaction is running (which involves a Claude API call that might take 1-3 seconds), any new WebSocket messages queue up waiting. The user might perceive this as lag or an unresponsive agent.
The cleanest fix: send a {"type": "compacting"} event to the browser before the compaction call, so the UI can show a brief indicator. The alternative is decoupling compaction into a separate Cloudflare Queue task, but that adds architectural complexity that's rarely worth it for most use cases.
Production Checklist
Before going live, verify these:
Auth: Move beyond simple token comparison to JWT validation with user-session binding. A user should only be able to access their own sessions.
Cost guardrails: Monitor totalTokensUsed per session. Set an upper limit — if a session exceeds, say, 100k tokens, either force compaction or return an error telling the user to start a fresh session. Without this, a single adversarial session could generate unexpectedly large bills.
Observability: Integrate with Cloudflare AI Gateway for production monitoring — it gives you latency histograms, error rates, and cost dashboards across all your Claude API calls with minimal code changes.
Async alternatives: If your use case involves fire-and-forget AI tasks rather than interactive chat, the Cloudflare Queues async pipeline pattern might be a better fit than WebSocket-based DOs.
Wrapping up
Durable Objects and Claude API is the combination I keep coming back to for serverless stateful AI. The actor model eliminates a whole class of concurrency bugs, the Hibernation API keeps costs reasonable, and Alarm-based cleanup means you're not babysitting session lifecycles.
If you want to start today, deploy this implementation with wrangler deploy, verify the WebSocket connection and message round-trip, then tune the compaction threshold and alarm TTL for your expected conversation volume. The compaction threshold in particular has a large impact on cost — run a few test conversations and check totalTokensUsed at different message counts to find the right balance for your workload.
Client-Side Implementation
The browser side is relatively simple once the server is in place. Here's a minimal client that handles the WebSocket lifecycle and renders streaming responses:
// client/agent-client.ts
interface AgentClientOptions {
sessionId: string;
apiToken: string;
onDelta: (text: string) => void;
onStart: () => void;
onEnd: () => void;
onError: (message: string) => void;
onCompacting?: () => void;
}
export class AgentClient {
private ws: WebSocket | null = null;
private options: AgentClientOptions;
private reconnectAttempts = 0;
private readonly MAX_RECONNECT_ATTEMPTS = 3;
constructor(options: AgentClientOptions) {
this.options = options;
}
connect(): Promise<void> {
return new Promise((resolve, reject) => {
const wsUrl = `wss://your-worker.example.com/agent/${this.options.sessionId}`;
this.ws = new WebSocket(wsUrl, [], {
headers: {
Authorization: `Bearer ${this.options.apiToken}`,
},
} as WebSocketInit);
this.ws.addEventListener("open", () => {
this.reconnectAttempts = 0;
resolve();
});
this.ws.addEventListener("message", (event) => {
let data: { type: string; text?: string; message?: string };
try {
data = JSON.parse(event.data);
} catch {
return;
}
switch (data.type) {
case "start":
this.options.onStart();
break;
case "delta":
if (data.text) this.options.onDelta(data.text);
break;
case "end":
this.options.onEnd();
break;
case "error":
this.options.onError(data.message || "Unknown error");
break;
case "compacting":
this.options.onCompacting?.();
break;
}
});
this.ws.addEventListener("error", (error) => {
reject(error);
});
this.ws.addEventListener("close", async (event) => {
// Attempt reconnection for non-intentional closes
if (event.code !== 1000 && this.reconnectAttempts < this.MAX_RECONNECT_ATTEMPTS) {
this.reconnectAttempts++;
const backoffMs = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 10000);
console.log(`WebSocket closed (${event.code}). Reconnecting in ${backoffMs}ms...`);
await new Promise((r) => setTimeout(r, backoffMs));
this.connect().catch(console.error);
}
});
});
}
sendMessage(content: string): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error("WebSocket is not connected");
}
this.ws.send(JSON.stringify({ type: "message", content }));
}
disconnect(): void {
this.ws?.close(1000, "Client disconnecting");
this.ws = null;
}
}Exponential backoff on reconnection is worth implementing even in early versions. Mobile networks and flaky connections will close your WebSocket unexpectedly, and without reconnection logic, users lose their session mid-conversation.
Testing the Implementation Locally
Testing Durable Objects locally requires Wrangler's development server with the --local flag. Unlike regular Workers where wrangler dev is straightforward, DOs need specific handling:
# Start local dev server with DO support
wrangler dev --local
# In another terminal, test WebSocket connection with wscat
npx wscat -c "ws://localhost:8787/agent/test-session-001" \
-H "Authorization: Bearer your-local-api-secret"
# Send a message (after connection)
> {"type":"message","content":"Hello, what's 2+2?"}
# Expected output sequence:
# {"type":"start"}
# {"type":"delta","text":"2"}
# {"type":"delta","text":" +"}
# {"type":"delta","text":" 2"}
# {"type":"delta","text":" ="}
# {"type":"delta","text":" 4."}
# {"type":"end"}For automated testing, the DO's /history and /reset endpoints are useful. After testing a multi-turn conversation, you can verify that the correct messages were stored:
# Check session history via HTTP
curl "http://localhost:8787/agent/test-session-001/history" \
-H "Authorization: Bearer your-local-api-secret" | jq .
# Reset session for next test
curl -X POST "http://localhost:8787/agent/test-session-001/reset" \
-H "Authorization: Bearer your-local-api-secret"One thing that trips people up with local DO testing: the storage is persistent across wrangler dev restarts when using --local. If you're running integration tests, always call /reset at the start of each test run to ensure clean state.
Scaling Considerations: When One DO per Session Isn't Enough
The single-DO-per-session model works well for personal assistants, customer support chatbots, and most AI chat applications. But there are scenarios where you need more:
Shared sessions (multiple users, one conversation)
A team chat with AI involvement — where multiple users contribute to the same session — requires careful thought. DOs are still the right primitive, but you need to change how you handle user identity within the session. Instead of treating all messages as coming from a single "user" role, you'd embed the sender's name in the message content:
// Modified message structure for multi-user sessions
const userMessage: Message = {
role: "user",
content: `[${userName}]: ${userContent}`,
timestamp: Date.now(),
};This keeps the Claude API's user/assistant role alternation intact while tracking who said what in the conversation text itself.
Extremely high message volume
A single DO handles one request at a time. For a customer support product where a single "conversation thread" receives dozens of messages per second (rare, but possible with automated testing tools), you'd need a fan-out architecture. In practice, this is almost never a real constraint for human-speed conversations.
Long-running background processes
If your AI agent needs to run for 30+ minutes (say, a research agent that browses the web and synthesizes findings), you'll hit DO's 30-second CPU time limit per request. The solution: break the work into stages, with each stage stored as a "checkpoint" in DO Storage. The DO processes each checkpoint in a separate request, with the Alarm API triggering the next stage automatically.
Monitoring Token Usage and Cost
totalTokensUsed in session state gives you per-session cost data, but you need a way to aggregate and alert on it. Here's a simple pattern using Cloudflare Analytics Engine:
// Add to AgentSession class, call after each successful message exchange
private async logUsageMetrics(inputTokens: number, outputTokens: number): Promise<void> {
try {
// Analytics Engine requires the binding to be configured in wrangler.toml
// [[analytics_engine_datasets]]
// binding = "ANALYTICS"
// dataset = "ai_agent_usage"
this.env.ANALYTICS?.writeDataPoint({
indexes: ["session_token_usage"],
blobs: ["conversation"],
doubles: [
inputTokens,
outputTokens,
inputTokens + outputTokens,
this.sessionData.totalTokensUsed,
],
});
} catch {
// Analytics failures are non-fatal
}
}With Analytics Engine data, you can query in Cloudflare's dashboard:
-- Total tokens used in the last 24 hours
SELECT SUM(_sample_interval * double3) as total_tokens
FROM ai_agent_usage
WHERE timestamp > NOW() - INTERVAL '24' HOURSet up a Cloudflare notification on your Worker error rate and, if possible, watch the totalTokensUsed distribution across sessions. Sessions with unusually high token counts (10x the average) often indicate either a bug in your compaction logic or deliberate prompt injection — worth investigating before they drive up your bill.
Wrapping Up
The Durable Objects + Claude API pattern solves a real problem: maintaining coherent, cost-controlled AI conversations in a serverless environment without managing your own infrastructure. The actor model eliminates concurrency bugs. Hibernation keeps costs reasonable. Alarms handle session lifecycle. The compaction pattern keeps context windows manageable.
The most important thing to tune after your initial deployment is the compaction threshold. If you set it too high (say, 50+ messages before compacting), your input token count per request balloons and costs rise faster than necessary. If you set it too low (4-6 messages), you lose context quickly and the AI starts forgetting things the user said earlier. For most conversational applications, 16-24 messages before compacting, keeping 4-8 recent messages, is a reasonable starting range.
Run a few realistic test conversations, measure totalTokensUsed at various message counts, and find the threshold that balances context quality with cost efficiency for your specific use case.
Security Hardening for Production
The authentication implementation shown earlier is minimal by design — it gets the concept across without obscuring the DO architecture. In production, you need several additional layers.
Session ID validation
Session IDs constructed from user input should be validated to prevent path traversal or injection. A simple UUID format check catches most abuse:
function validateSessionId(sessionId: string): boolean {
// Allow alphanumeric, hyphens, underscores, max 64 chars
return /^[a-zA-Z0-9_-]{8,64}$/.test(sessionId);
}
// In the Worker Router fetch handler
if (!validateSessionId(sessionId)) {
return new Response("Invalid session ID", { status: 400 });
}User-session binding
The route handler should verify that the authenticated user actually owns the session they're requesting. One approach: encode the user ID into the session ID itself (e.g., ${userId}-${randomId}), then verify the prefix matches the authenticated user. Another: maintain a separate KV mapping of sessionId → userId and check on every request.
Input length limits
Unbounded user input can trigger disproportionately expensive Claude API calls. Add a content length check before processing:
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
if (typeof message !== "string") return;
if (message.length > 32768) { // 32KB limit
ws.send(JSON.stringify({ type: "error", message: "Message too long" }));
return;
}
// ... rest of handler
}Rate limiting per session
Prevent a single session from making too many requests in a short window. DO Storage can track request timing:
private async checkRateLimit(): Promise<boolean> {
const now = Date.now();
const windowMs = 60_000; // 1 minute
const maxRequests = 20;
const timestamps: number[] = (await this.state.storage.get("rateLimit")) || [];
const recent = timestamps.filter((t) => now - t < windowMs);
if (recent.length >= maxRequests) {
return false; // Rate limited
}
recent.push(now);
await this.state.storage.put("rateLimit", recent);
return true;
}Call this at the start of processUserMessage and return an error response if it returns false.
wrangler.toml Configuration
Complete configuration for the project:
name = "my-ai-agent"
main = "src/index.ts"
compatibility_date = "2026-01-01"
compatibility_flags = ["nodejs_compat"]
[vars]
SYSTEM_PROMPT = "You are a helpful assistant specialized in software development."
[secrets]
# Set with: wrangler secret put ANTHROPIC_API_KEY
# Set with: wrangler secret put API_SECRET
ANTHROPIC_API_KEY = ""
API_SECRET = ""
[[durable_objects.bindings]]
name = "AGENT"
class_name = "AgentSession"
[[migrations]]
tag = "v1"
new_classes = ["AgentSession"]
[[analytics_engine_datasets]]
binding = "ANALYTICS"
dataset = "ai_agent_usage"
# Optional: limit DO CPU time per request to prevent runaway costs
[limits]
cpu_ms = 30000Deploy with:
# Deploy to production
wrangler deploy
# Set secrets (don't put these in wrangler.toml)
wrangler secret put ANTHROPIC_API_KEY
wrangler secret put API_SECRET
# Verify deployment
wrangler tail --format prettyThe wrangler tail command is invaluable during initial deployment — it streams live logs from your Worker and DO so you can see session creation, API calls, and any errors in real time.