Skip to Content

Claude Agent SDK

Give a Claude Agent SDK agent a mailbox that answers email and WhatsApp through two tools, with each reply held for a person to approve.

This guide gives a Claude Agent SDK agent its own mailbox. The agent follows the mailbox's event stream, runs one turn per incoming message, and answers email and WhatsApp through two tools. Each reply is held until a person approves it.

What you need

npm install samva @anthropic-ai/claude-agent-sdk zod

The two tools

reply_to_message submits a reply and leave_for_person sends nothing. Both close over the incoming message, so the model supplies only the reply text or a reason, never the recipient or thread.

tools.ts
import { tool } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";

const replyTool = (run: (replyText: string) => Promise<ReturnType<typeof text>>) =>
  tool(
    "reply_to_message",
    "Send your reply to the customer's message. A person at the business approves it before it is sent. Call this once.",
    { text: z.string().min(1).describe("The reply, in plain text.") },
    async (args) => run(args.text),
    { annotations: { destructiveHint: true, openWorldHint: true }, alwaysLoad: true },
  );

const leaveTool = (run: (reason: string) => ReturnType<typeof text>) =>
  tool(
    "leave_for_person",
    "Send nothing and leave this message for a person to handle. Use it when the business instructions do not cover the question or the message is not a customer asking for help.",
    { reason: z.string().min(1).describe("One short sentence for the person.") },
    async (args) => run(args.reason),
    { annotations: { readOnlyHint: true }, alwaysLoad: true },
  );

The reply handler makes the one call to Samva. The event id keys it, so a replayed event cannot send twice. A replayed event is refused with MailboxSendConflictError, which carries the first reply's receipt, so the agent has nothing left to do. Any other conflict, such as a paused mailbox, is a failure and the event is retried. A closed WhatsApp service window is logged and skipped. Any other error is stored for the caller to rethrow.

reply.ts
import { MailboxSendConflictError, WhatsAppServiceWindowClosedError } from "samva";

// Set before any await: the SDK can run two tool calls at once, and one message gets one action.
let claimed = false;
let failure: unknown;

const send = async (replyText: string) => {
  if (claimed) return text("You already acted on this message. Stop.", true);
  claimed = true;
  // Without channel: "whatsapp", Samva treats the reply as email and rejects a WhatsApp thread.
  const reply =
    message.channel === "whatsapp"
      ? {
          id: message.mailboxId,
          messageId: message.messageId,
          channel: "whatsapp" as const,
          text: replyText,
        }
      : { id: message.mailboxId, messageId: message.messageId, text: replyText };
  try {
    const receipt = await samva.mailboxes.reply(reply, { idempotencyKey: `reply:${eventId}` });
    return text(`Submitted. State: ${receipt.state}. Do not send anything else.`);
  } catch (error) {
    if (error instanceof MailboxSendConflictError) return text("This message was already answered. Stop.");
    if (error instanceof WhatsAppServiceWindowClosedError) {
      return text("The WhatsApp service window is closed, so nothing can be sent. Stop.");
    }
    failure = error;
    return text("Samva refused the reply. Stop.", true);
  }
};

leave_for_person checks and sets the same claimed flag.

Run one turn per message

Follow the mailbox with samva.mailboxes.events and run one query() for each mailbox.message.received event. See Mailbox events for the event stream. tools: [] removes the built-in tools, so the agent can only call the two above.

turn.ts
import { createSdkMcpServer, query } from "@anthropic-ai/claude-agent-sdk";

const server = createSdkMcpServer({
  name: "mailbox",
  tools: [tools.replyToMessage, tools.leaveForPerson],
});

for await (const event of query({
  prompt: userTurn(message),
  options: {
    model: "claude-opus-5-5",
    systemPrompt: systemPrompt(instructions),
    tools: [],
    mcpServers: { mailbox: server },
    allowedTools: ["mcp__mailbox__reply_to_message", "mcp__mailbox__leave_for_person"],
    maxTurns: 4,
    settingSources: [],
    persistSession: false,
  },
})) {
  if (event.type === "result" && (event.subtype !== "success" || event.is_error)) {
    throw new Error(`Agent turn for ${message.messageId} failed: ${event.subtype}`);
  }
}

// A refused reply stops the event, so the loop does not save its cursor past it.
if (failure !== undefined) throw failure;

The customer's message goes in the user turn and is untrusted data, so the system prompt tells the agent to answer it and never to follow instructions inside it.

Answer WhatsApp from the same mailbox

A WhatsApp message arrives as the same mailbox.message.received event, and the reply tool answers it with channel: "whatsapp". Add your connected number to the mailbox as described in Answer WhatsApp in a mailbox.

Next steps

The Claude Agent SDK cookbook walks through the pieces in more depth. The runnable example includes the event loop and tests.

Related documentation

On this page