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
- A mailbox, as in Give an agent an inbox.
- A mailbox-scoped key for the agent with a rule that holds its sends for a person. See Agents and keys and Approve sends.
npm install samva @anthropic-ai/claude-agent-sdk zodThe 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.
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.
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.
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.