LangGraph
Give a LangGraph agent a mailbox that answers email and WhatsApp through two tools, with each reply held for a person to approve.
This guide gives a LangGraph agent its own mailbox. The agent follows the mailbox's event stream, runs once 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 langchain @langchain/core @langchain/anthropic zodThe two tools
reply_to_customer submits a reply and leave_for_person sends nothing. Both close over the
incoming message and event id, so the model supplies only the reply text or a reason. The event id
keys the reply, 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 and the tool reports it as done. 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
recorded as failed for the caller to throw. returnDirect: true ends the run after either tool.
import { tool } from "@langchain/core/tools";
import { MailboxSendConflictError, WhatsAppServiceWindowClosedError } from "samva";
import { z } from "zod";
let outcome: Outcome = { kind: "none" };
// Set before any await: a model can call both tools in one turn, and the calls run together.
let claimed = false;
const reply_to_customer = tool(
async ({ text }) => {
if (claimed) return "You already finished this message. Do not call again.";
claimed = true;
// Without channel: "whatsapp", Samva treats the reply as email and rejects a WhatsApp thread.
const reply =
received.channel === "whatsapp"
? {
id: received.mailboxId,
messageId: received.messageId,
channel: "whatsapp" as const,
text,
}
: { id: received.mailboxId, messageId: received.messageId, text };
try {
const receipt = await samva.mailboxes.reply(reply, { idempotencyKey: `reply:${eventId}` });
outcome = { kind: "replied" };
return `Submitted. The reply is ${receipt.state}.`;
} catch (error) {
if (error instanceof MailboxSendConflictError) {
outcome = { kind: "replied" };
log(
`already answered ${received.messageId}: ${error.receipt.state}, action ${error.receipt.actionId}`,
);
return "This message was already answered. Do not call again.";
}
if (error instanceof WhatsAppServiceWindowClosedError) {
outcome = { kind: "left" };
log(`skipped ${received.messageId}: WhatsApp service window closed`);
return "The WhatsApp window is closed, so nothing was sent. Do not call again.";
}
outcome = { kind: "failed", error };
return "Sending failed. Do not call again.";
}
},
{
name: "reply_to_customer",
description:
"Reply to the customer's message with plain text. The reply is held for a person's approval.",
schema: z.object({ text: z.string().trim().min(1).describe("The reply text to send.") }),
// Both tools finish the message: end the run instead of calling the model again.
returnDirect: true,
},
);
const leave_for_person = tool(
async ({ reason }) => {
if (claimed) return "You already finished this message. Do not call again.";
claimed = true;
outcome = { kind: "left" };
log(`left ${received.messageId} for a person: ${reason}`);
return "Left for a person. Nothing was sent.";
},
{
name: "leave_for_person",
description:
"Send nothing and leave the message for a person. Use when the instructions do not cover it.",
schema: z.object({ reason: z.string().describe("One short sentence on why.") }),
returnDirect: true,
},
);Run one invocation per message
Follow the mailbox with samva.mailboxes.events and invoke a createAgent agent for each
mailbox.message.received event. See Mailbox events for the event
stream. recursionLimit stops a model that keeps looping.
import { ChatAnthropic } from "@langchain/anthropic";
import { createAgent } from "langchain";
const model = new ChatAnthropic({ model: "claude-opus-5-5" });
for await (const event of samva.mailboxes.events({ mailboxIds: [mailboxId] })) {
if (event.type !== "mailbox.message.received") continue;
const received = parseReceivedMessage(event.data);
if (received === undefined) continue;
const { tools, outcome } = messageTools(samva, received, event.id, console.log);
const agent = createAgent({ model, tools, systemPrompt: systemPrompt(instructions) });
await agent.invoke(
{ messages: [{ role: "user", content: userTurn(received) }] },
{ recursionLimit: 8 },
);
// A refused reply stops the event, so the loop does not save a cursor past it.
const result = outcome();
if (result.kind === "failed") throw result.error;
}The customer's message goes in the user turn and is untrusted data, so the system prompt tells the model 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 LangGraph cookbook walks through the pieces in more depth. The runnable example includes the event loop and tests.