Agents SDK
Build a stateful AI agent that maintains conversation history, calls tools, and persists state across reconnections. The Agents SDK wraps Durable Objects with agent-specific features: state management, tool definitions, WebSocket connections, and client libraries.
Prerequisites: Agents Model, Durable Objects
Setup
npm install agents hono hono-agents
Gotcha: The
agentspackage is pre-1.0. The API may change between minor versions. Pin your version inpackage.json.
wrangler.jsonc:
{
"name": "agent-demo",
"main": "src/index.ts",
"compatibility_date": "2025-01-01",
"compatibility_flags": ["nodejs_compat"],
"ai": {
"binding": "AI"
},
"durable_objects": {
"bindings": [
{
"name": "ASSISTANT",
"class_name": "AssistantAgent"
}
]
},
"migrations": [
{
"tag": "v1",
"new_classes": ["AssistantAgent"]
}
]
}
Run npx wrangler types to update the Env interface.
The Agent: Server Side
Here is a complete agent with three tools: fetch weather, save a note, and list notes. It maintains conversation state across disconnections.
// src/agent.ts
import { Agent } from "agents";
interface AssistantState {
notes: string[];
messageCount: number;
}
interface Env {
AI: Ai;
ASSISTANT: DurableObjectNamespace;
}
export class AssistantAgent extends Agent<Env, AssistantState> {
initialState: AssistantState = {
notes: [],
messageCount: 0,
};
async onStart(): Promise<void> {
// Called once when the agent is first created
console.log("Agent started with state:", this.state);
}
async onConnect(connection: Connection, ctx: ConnectionContext): Promise<void> {
// Send current state when a client connects
connection.send(
JSON.stringify({ type: "state", data: this.state })
);
connection.send(
JSON.stringify({
type: "system",
content: `Welcome! You have ${this.state.notes.length} saved notes.`,
})
);
}
async onMessage(connection: Connection, message: string): Promise<void> {
const data = JSON.parse(message);
if (data.type === "chat") {
await this.handleChat(connection, data.content);
}
}
async onClose(connection: Connection): Promise<void> {
console.log("Client disconnected");
}
// --- Tools ---
async getWeather(location: string): Promise<string> {
// In production, call a real weather API
const res = await fetch(
`https://wttr.in/${encodeURIComponent(location)}?format=j1`
);
if (!res.ok) return `Could not fetch weather for ${location}`;
const data = await res.json<any>();
const current = data.current_condition?.[0];
return `${location}: ${current?.temp_C}C, ${current?.weatherDesc?.[0]?.value}`;
}
async saveNote(content: string): Promise<string> {
this.setState({
...this.state,
notes: [...this.state.notes, content],
});
return `Saved note: "${content}"`;
}
async listNotes(): Promise<string> {
if (this.state.notes.length === 0) return "No notes saved yet.";
return this.state.notes
.map((note, i) => `${i + 1}. ${note}`)
.join("\n");
}
// --- Chat handling ---
private async handleChat(
connection: Connection,
userMessage: string
): Promise<void> {
// Update message count
this.setState({
...this.state,
messageCount: this.state.messageCount + 1,
});
// Build the conversation for the LLM
const systemPrompt = `You are a helpful assistant. You have access to these tools:
- getWeather(location): Get current weather for a city
- saveNote(content): Save a note for the user
- listNotes(): List all saved notes
When the user asks you to do something that matches a tool, respond with a JSON tool call:
{"tool": "toolName", "args": ["arg1"]}
Otherwise, respond normally. The user currently has ${this.state.notes.length} notes saved.`;
const response = await this.env.AI.run(
"@cf/meta/llama-3.1-8b-instruct",
{
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userMessage },
],
}
);
const text = response.response ?? "";
// Check if the LLM wants to call a tool
const toolResult = await this.tryToolCall(text);
if (toolResult) {
connection.send(
JSON.stringify({
type: "assistant",
content: toolResult,
})
);
} else {
connection.send(
JSON.stringify({
type: "assistant",
content: text,
})
);
}
}
private async tryToolCall(text: string): Promise<string | null> {
try {
// Look for JSON tool call in the response
const match = text.match(/\{[\s\S]*"tool"[\s\S]*\}/);
if (!match) return null;
const call = JSON.parse(match[0]);
const { tool, args = [] } = call;
switch (tool) {
case "getWeather":
return await this.getWeather(args[0]);
case "saveNote":
return await this.saveNote(args[0]);
case "listNotes":
return await this.listNotes();
default:
return null;
}
} catch {
return null;
}
}
}
The Worker: Routing
Use hono-agents middleware to route agent connections:
// src/index.ts
import { Hono } from "hono";
import { agentsMiddleware } from "hono-agents";
// Re-export the agent class so the runtime can find it
export { AssistantAgent } from "./agent";
const app = new Hono<{ Bindings: Env }>();
// The middleware handles WebSocket connections to agents
app.use("*", agentsMiddleware());
// Your regular API routes still work
app.get("/api/health", (c) => c.json({ ok: true }));
export default app;
The agentsMiddleware intercepts requests that target agent instances and routes them to the correct Durable Object. All other requests pass through to your Hono routes.
The Client: Browser Side
React
import { useAgent } from "agents/react";
function Chat() {
const [messages, setMessages] = useState<string[]>([]);
const [input, setInput] = useState("");
const agent = useAgent({
agent: "AssistantAgent",
name: "user-session-123",
onStateUpdate: (state) => {
console.log("Agent state:", state);
},
onMessage: (event) => {
const data = JSON.parse(event.data);
if (data.type === "assistant" || data.type === "system") {
setMessages((prev) => [...prev, data.content]);
}
},
});
const send = () => {
agent.send(JSON.stringify({ type: "chat", content: input }));
setMessages((prev) => [...prev, `You: ${input}`]);
setInput("");
};
return (
<div>
<div>
{messages.map((msg, i) => (
<p key={i}>{msg}</p>
))}
</div>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button onClick={send}>Send</button>
</div>
);
}
Vanilla JavaScript
import { AgentClient } from "agents/client";
const client = new AgentClient({
agent: "AssistantAgent",
name: "user-session-123",
host: "agent-demo.your-subdomain.workers.dev",
onStateUpdate: (state, source) => {
console.log(`State from ${source}:`, state);
document.getElementById("notes-count")!.textContent =
String(state.notes.length);
},
});
// Send a chat message
document.getElementById("send")!.addEventListener("click", () => {
const input = document.getElementById("input") as HTMLInputElement;
client.send(JSON.stringify({ type: "chat", content: input.value }));
input.value = "";
});
// Or call agent methods directly via RPC
const notes = await client.call("listNotes", []);
console.log("Notes:", notes);
The AgentClient connects via WebSocket and automatically syncs state. When the agent calls this.setState(), your onStateUpdate callback fires on every connected client.
State Persistence
Agent state survives everything:
- Page refresh: Client reconnects, receives full state
- Server restart: Durable Object rehydrates from storage
- Deployment: New code runs, existing state is preserved
- Disconnection: Agent hibernates, state is intact
The name parameter in the client determines which agent instance you connect to. Same name = same state. Different names = separate agents.
// These connect to different agent instances with separate state
const personalAgent = new AgentClient({ agent: "AssistantAgent", name: "peter" });
const teamAgent = new AgentClient({ agent: "AssistantAgent", name: "team-general" });
SQL Storage
For structured data beyond the state object, use the built-in SQLite:
export class DataAgent extends Agent<Env, DataState> {
async onStart(): Promise<void> {
this.sql`CREATE TABLE IF NOT EXISTS queries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
question TEXT NOT NULL,
answer TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
)`;
}
async logQuery(question: string, answer: string): Promise<void> {
this.sql`INSERT INTO queries (question, answer)
VALUES (${question}, ${answer})`;
}
async getHistory(): Promise<unknown[]> {
return this.sql`SELECT * FROM queries
ORDER BY created_at DESC LIMIT 20`;
}
}
Testing Locally
npx wrangler dev
The agent runs locally on workerd with a real Durable Object. WebSocket connections work through ws://localhost:8787. State persists in .wrangler/state/ between restarts.
Test via curl (HTTP endpoint):
curl http://localhost:8787/api/health
For WebSocket testing, use the browser client or a tool like wscat:
npx wscat -c "ws://localhost:8787/agents/AssistantAgent/user-session-123"
Gotcha: Agent names are case-sensitive.
"MyAgent"and"myagent"are different instances with different state.
Gotcha: For Hono integration, the
hono-agentsmiddleware must be registered before your routes. It intercepts agent-bound requests and passes everything else through.
What’s Next
- AI Gateway - Add caching and fallback to the agent’s LLM calls
- Vectorize RAG - Give the agent access to a knowledge base via RAG
- Agent Patterns - Scheduled agents, MCP, human-in-the-loop, multi-agent