Agent Patterns

The Agents SDK quickstart covers basic agent setup: state, tools, and WebSocket connections. This page covers advanced patterns for production agents: scheduled execution, MCP integration, human-in-the-loop workflows, real-time collaboration, and multi-agent coordination.

Prerequisites: Agents SDK quickstart

Scheduled Agents

Agents can run on a cron schedule using Cloudflare’s cron triggers. The Worker receives the cron event and delegates to an agent instance.

// src/index.ts
import { Hono } from "hono";
import { agentsMiddleware } from "hono-agents";

export { MonitorAgent } from "./monitor-agent";

const app = new Hono<{ Bindings: Env }>();
app.use("*", agentsMiddleware());

export default {
  fetch: app.fetch,

  async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
    // Get or create a named agent instance
    const id = env.MONITOR.idFromName("daily-check");
    const agent = env.MONITOR.get(id);

    // Send a task to the agent via HTTP
    await agent.fetch(new Request("http://internal/run-check", {
      method: "POST",
      body: JSON.stringify({ trigger: event.cron }),
    }));
  },
};

The agent handles the request and maintains state across invocations:

// src/monitor-agent.ts
import { Agent } from "agents";

interface MonitorState {
  lastRun: string | null;
  checksCompleted: number;
  alerts: string[];
}

export class MonitorAgent extends Agent<Env, MonitorState> {
  initialState: MonitorState = {
    lastRun: null,
    checksCompleted: 0,
    alerts: [],
  };

  async onRequest(request: Request): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname === "/run-check") {
      const result = await this.runHealthChecks();
      return Response.json(result);
    }

    return new Response("Not found", { status: 404 });
  }

  private async runHealthChecks(): Promise<{ status: string }> {
    const endpoints = [
      "https://api.example.com/health",
      "https://cdn.example.com/status",
    ];

    const results = await Promise.allSettled(
      endpoints.map((url) => fetch(url))
    );

    const failures = results
      .map((r, i) => ({ result: r, endpoint: endpoints[i] }))
      .filter((r) => r.result.status === "rejected");

    if (failures.length > 0) {
      const alerts = failures.map((f) => `${f.endpoint} is down`);
      this.setState({
        ...this.state,
        alerts: [...this.state.alerts.slice(-50), ...alerts],
        lastRun: new Date().toISOString(),
        checksCompleted: this.state.checksCompleted + 1,
      });
    } else {
      this.setState({
        ...this.state,
        lastRun: new Date().toISOString(),
        checksCompleted: this.state.checksCompleted + 1,
      });
    }

    return { status: failures.length > 0 ? "alerts" : "ok" };
  }
}

wrangler.jsonc:

{
  "triggers": {
    "crons": ["0 */6 * * *"]
  },
  "durable_objects": {
    "bindings": [
      {
        "name": "MONITOR",
        "class_name": "MonitorAgent"
      }
    ]
  },
  "migrations": [
    {
      "tag": "v1",
      "new_classes": ["MonitorAgent"]
    }
  ]
}

Gotcha: Use event.cron to identify which schedule triggered the handler if you have multiple cron entries. To test locally: curl http://localhost:8787/__scheduled?cron=0+*/6+*+*+*

MCP Server Integration

An agent can act as an MCP (Model Context Protocol) client, calling external tools exposed by MCP servers. This lets the agent use tools it doesn’t implement itself - file systems, databases, APIs, other AI services.

import { Agent } from "agents";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

interface McpAgentState {
  availableTools: string[];
  lastToolCall: string | null;
}

export class McpAgent extends Agent<Env, McpAgentState> {
  initialState: McpAgentState = {
    availableTools: [],
    lastToolCall: null,
  };

  private async getMcpClient(): Promise<Client> {
    const client = new Client({
      name: "cloudflare-agent",
      version: "1.0.0",
    });

    const transport = new StreamableHTTPClientTransport(
      new URL("https://mcp-server.example.com/mcp")
    );

    await client.connect(transport);
    return client;
  }

  async onMessage(connection: Connection, message: string): Promise<void> {
    const data = JSON.parse(message);

    if (data.type === "chat") {
      const mcpClient = await this.getMcpClient();

      try {
        // List available tools
        const tools = await mcpClient.listTools();
        this.setState({
          ...this.state,
          availableTools: tools.tools.map((t) => t.name),
        });

        // Ask the LLM which tool to call
        const toolDecision = await this.decideToolCall(
          data.content,
          tools.tools
        );

        if (toolDecision) {
          // Call the MCP tool
          const result = await mcpClient.callTool({
            name: toolDecision.tool,
            arguments: toolDecision.args,
          });

          this.setState({
            ...this.state,
            lastToolCall: toolDecision.tool,
          });

          connection.send(JSON.stringify({
            type: "tool-result",
            tool: toolDecision.tool,
            result: result.content,
          }));
        }
      } finally {
        await mcpClient.close();
      }
    }
  }

  private async decideToolCall(
    userMessage: string,
    tools: { name: string; description?: string }[]
  ): Promise<{ tool: string; args: Record<string, unknown> } | null> {
    const toolList = tools
      .map((t) => `- ${t.name}: ${t.description ?? "no description"}`)
      .join("\n");

    const response = await this.env.AI.run("@cf/meta/llama-3.1-8b-instruct", {
      messages: [
        {
          role: "system",
          content: `You have access to these tools:\n${toolList}\n\nIf the user's request matches a tool, respond with JSON: {"tool": "name", "args": {}}. Otherwise respond with null.`,
        },
        { role: "user", content: userMessage },
      ],
    });

    try {
      const match = (response.response ?? "").match(/\{[\s\S]*"tool"[\s\S]*\}/);
      return match ? JSON.parse(match[0]) : null;
    } catch {
      return null;
    }
  }
}

This pattern works for any MCP server: local filesystem tools, database connectors, or third-party APIs. The agent discovers available tools dynamically and routes user requests to the right tool.

Human-in-the-Loop

Some agent actions need human approval before executing - sending emails, making payments, deleting data. The agent pauses, notifies the user, and resumes when approved.

The WebSocket connection makes this natural: the agent sends an approval request, the client shows a UI prompt, and the user’s response triggers the action.

import { Agent } from "agents";

interface ApprovalState {
  pendingActions: PendingAction[];
  completedActions: string[];
}

interface PendingAction {
  id: string;
  description: string;
  action: string;
  args: Record<string, unknown>;
  requestedAt: string;
}

export class ApprovalAgent extends Agent<Env, ApprovalState> {
  initialState: ApprovalState = {
    pendingActions: [],
    completedActions: [],
  };

  async onMessage(connection: Connection, message: string): Promise<void> {
    const data = JSON.parse(message);

    switch (data.type) {
      case "chat":
        await this.handleChat(connection, data.content);
        break;

      case "approve":
        await this.handleApproval(connection, data.actionId, true);
        break;

      case "reject":
        await this.handleApproval(connection, data.actionId, false);
        break;
    }
  }

  private async handleChat(
    connection: Connection,
    userMessage: string
  ): Promise<void> {
    // LLM decides what action to take
    const action = await this.planAction(userMessage);

    if (action && action.requiresApproval) {
      // Queue the action for approval
      const pending: PendingAction = {
        id: crypto.randomUUID(),
        description: action.description,
        action: action.name,
        args: action.args,
        requestedAt: new Date().toISOString(),
      };

      this.setState({
        ...this.state,
        pendingActions: [...this.state.pendingActions, pending],
      });

      // Notify all connected clients
      this.broadcast(JSON.stringify({
        type: "approval-required",
        action: pending,
      }));
    } else if (action) {
      // Execute immediately
      const result = await this.executeAction(action.name, action.args);
      connection.send(JSON.stringify({
        type: "assistant",
        content: result,
      }));
    }
  }

  private async handleApproval(
    connection: Connection,
    actionId: string,
    approved: boolean
  ): Promise<void> {
    const action = this.state.pendingActions.find((a) => a.id === actionId);
    if (!action) return;

    // Remove from pending
    this.setState({
      ...this.state,
      pendingActions: this.state.pendingActions.filter(
        (a) => a.id !== actionId
      ),
    });

    if (approved) {
      const result = await this.executeAction(action.action, action.args);
      this.setState({
        ...this.state,
        completedActions: [
          ...this.state.completedActions,
          `${action.description} - completed`,
        ],
      });

      this.broadcast(JSON.stringify({
        type: "action-completed",
        actionId,
        result,
      }));
    } else {
      this.broadcast(JSON.stringify({
        type: "action-rejected",
        actionId,
      }));
    }
  }

  private async executeAction(
    name: string,
    args: Record<string, unknown>
  ): Promise<string> {
    // Execute the actual action
    switch (name) {
      case "send-email":
        return `Email sent to ${args.to}`;
      case "delete-record":
        return `Record ${args.id} deleted`;
      default:
        return `Unknown action: ${name}`;
    }
  }

  private async planAction(userMessage: string): Promise<{
    name: string;
    description: string;
    args: Record<string, unknown>;
    requiresApproval: boolean;
  } | null> {
    // LLM decides the action and whether it needs approval
    const response = await this.env.AI.run("@cf/meta/llama-3.1-8b-instruct", {
      messages: [
        {
          role: "system",
          content: `Decide the action. Destructive or external actions (send email, delete, pay) require approval. Respond with JSON: {"name": "action", "description": "what it does", "args": {}, "requiresApproval": true/false}`,
        },
        { role: "user", content: userMessage },
      ],
    });

    try {
      const match = (response.response ?? "").match(/\{[\s\S]*"name"[\s\S]*\}/);
      return match ? JSON.parse(match[0]) : null;
    } catch {
      return null;
    }
  }
}

The state-sync mechanism makes this robust: if the user closes their browser and comes back, the pending actions are still in state. The agent doesn’t lose the context.

Real-Time Collaborative Agents

Multiple clients can connect to the same agent instance. Combined with this.broadcast(), this creates collaborative experiences where an AI agent mediates between users.

import { Agent } from "agents";

interface CollabState {
  document: string;
  editors: string[];
  revisionCount: number;
}

export class CollabAgent extends Agent<Env, CollabState> {
  initialState: CollabState = {
    document: "",
    editors: [],
    revisionCount: 0,
  };

  async onConnect(connection: Connection, ctx: ConnectionContext): Promise<void> {
    const userId = ctx.request.headers.get("x-user-id") ?? "anonymous";

    this.setState({
      ...this.state,
      editors: [...new Set([...this.state.editors, userId])],
    });

    // New client gets full state
    connection.send(JSON.stringify({
      type: "sync",
      document: this.state.document,
      editors: this.state.editors,
    }));
  }

  async onMessage(connection: Connection, message: string): Promise<void> {
    const data = JSON.parse(message);

    if (data.type === "edit") {
      this.setState({
        ...this.state,
        document: data.document,
        revisionCount: this.state.revisionCount + 1,
      });

      // Broadcast to all connected clients
      this.broadcast(JSON.stringify({
        type: "update",
        document: data.document,
        editor: data.userId,
        revision: this.state.revisionCount,
      }));
    }

    if (data.type === "ai-assist") {
      // AI helps with the current document
      const response = await this.env.AI.run(
        "@cf/meta/llama-3.1-8b-instruct",
        {
          messages: [
            {
              role: "system",
              content: `You are a writing assistant. The current document is:\n\n${this.state.document}`,
            },
            { role: "user", content: data.prompt },
          ],
        }
      );

      this.broadcast(JSON.stringify({
        type: "ai-suggestion",
        content: response.response,
        prompt: data.prompt,
      }));
    }
  }

  async onClose(connection: Connection): Promise<void> {
    // Note: removing editors requires tracking connection-to-user mapping
    // which you'd handle via connection tags or a separate map
  }
}

The single-instance guarantee from Durable Objects means there are no race conditions. All messages to one agent instance are serialized, so concurrent edits are naturally ordered.

Multi-Agent Coordination

For complex tasks, split the work across specialized agents. An orchestrator agent delegates to worker agents, each responsible for a specific domain.

flowchart TD
    U["User"] -->|Request| O["Orchestrator Agent"]

    O -->|"Research task"| R["Research Agent"]
    O -->|"Draft task"| W["Writer Agent"]
    O -->|"Review task"| V["Reviewer Agent"]

    R -->|Findings| O
    W -->|Draft| O
    V -->|Feedback| O

    O -->|"Final result"| U

The orchestrator delegates tasks via HTTP to other agent instances:

import { Agent } from "agents";

interface OrchestratorState {
  currentTask: string | null;
  delegatedTasks: DelegatedTask[];
  results: Record<string, string>;
}

interface DelegatedTask {
  id: string;
  agent: string;
  task: string;
  status: "pending" | "complete" | "failed";
}

export class OrchestratorAgent extends Agent<Env, OrchestratorState> {
  initialState: OrchestratorState = {
    currentTask: null,
    delegatedTasks: [],
    results: {},
  };

  async onMessage(connection: Connection, message: string): Promise<void> {
    const data = JSON.parse(message);

    if (data.type === "task") {
      await this.orchestrate(connection, data.content);
    }
  }

  private async orchestrate(
    connection: Connection,
    task: string
  ): Promise<void> {
    this.setState({ ...this.state, currentTask: task });

    // Step 1: Research
    connection.send(JSON.stringify({
      type: "status",
      content: "Researching...",
    }));
    const research = await this.delegateToAgent(
      this.env.RESEARCHER,
      "research-agent",
      { task: "research", query: task }
    );
    this.setState({
      ...this.state,
      results: { ...this.state.results, research },
    });

    // Step 2: Write draft using research
    connection.send(JSON.stringify({
      type: "status",
      content: "Writing draft...",
    }));
    const draft = await this.delegateToAgent(
      this.env.WRITER,
      "writer-agent",
      { task: "write", context: research, prompt: task }
    );
    this.setState({
      ...this.state,
      results: { ...this.state.results, draft },
    });

    // Step 3: Review the draft
    connection.send(JSON.stringify({
      type: "status",
      content: "Reviewing...",
    }));
    const review = await this.delegateToAgent(
      this.env.REVIEWER,
      "reviewer-agent",
      { task: "review", draft, originalPrompt: task }
    );

    // Send final result
    connection.send(JSON.stringify({
      type: "complete",
      draft,
      review,
      research,
    }));

    this.setState({
      ...this.state,
      currentTask: null,
      results: { ...this.state.results, review },
    });
  }

  private async delegateToAgent(
    namespace: DurableObjectNamespace,
    name: string,
    payload: Record<string, string>
  ): Promise<string> {
    const id = namespace.idFromName(name);
    const agent = namespace.get(id);

    const response = await agent.fetch(
      new Request("http://internal/execute", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(payload),
      })
    );

    const result = await response.json<{ output: string }>();
    return result.output;
  }
}

Each worker agent is a standalone Agent class that handles its domain:

export class ResearchAgent extends Agent<Env, ResearchState> {
  async onRequest(request: Request): Promise<Response> {
    const { query } = await request.json<{ task: string; query: string }>();

    const response = await this.env.AI.run("@cf/meta/llama-3.1-8b-instruct", {
      messages: [
        {
          role: "system",
          content: "You are a research assistant. Provide concise, factual findings.",
        },
        { role: "user", content: query },
      ],
    });

    return Response.json({ output: response.response });
  }
}

wrangler.jsonc for multi-agent:

{
  "durable_objects": {
    "bindings": [
      { "name": "ORCHESTRATOR", "class_name": "OrchestratorAgent" },
      { "name": "RESEARCHER", "class_name": "ResearchAgent" },
      { "name": "WRITER", "class_name": "WriterAgent" },
      { "name": "REVIEWER", "class_name": "ReviewerAgent" }
    ]
  },
  "migrations": [
    {
      "tag": "v1",
      "new_classes": [
        "OrchestratorAgent",
        "ResearchAgent",
        "WriterAgent",
        "ReviewerAgent"
      ]
    }
  ]
}

Gotcha: Inter-agent communication uses fetch() through the DO binding. This is an internal HTTP call, not a subrequest to the internet. It counts against your subrequest limit (50 free / 10,000 paid) but is fast since it stays on Cloudflare’s network.

Pattern Selection

PatternWhen to Use
ScheduledPeriodic tasks: monitoring, digests, data sync
MCP clientAgent needs external tools it doesn’t implement
Human-in-the-loopDestructive or costly actions requiring approval
Real-time collabMultiple users interacting with the same agent
Multi-agentComplex tasks that benefit from domain specialization

Start with a single agent. Add patterns as your use case demands. Multi-agent coordination adds complexity; only use it when a single agent can’t handle the task’s breadth.

What’s Next