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 agents package is pre-1.0. The API may change between minor versions. Pin your version in package.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-agents middleware 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