Vercel AI SDK
Give a Vercel AI 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 Vercel AI 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 ai @ai-sdk/anthropic samva 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. The tool stores a failed submission in
outcome.error instead of throwing, so the model never sees it. claimed is set before any await
because a model can call both tools in one step.
import { tool } from "ai";
import type { SamvaClient } from "samva";
import { z } from "zod";
import type { ReceivedMessage } from "./message";
export interface TurnOutcome {
reply?: { readonly state: string; readonly actionId: string };
error?: unknown;
leftForPerson?: string;
}
export const mailboxTools = (
samva: SamvaClient,
message: ReceivedMessage,
eventId: string,
outcome: TurnOutcome,
) => {
// Set before any await: a model can call both tools in one step, and the calls run together.
let claimed = false;
return {
reply_to_customer: tool({
description:
"Submit a reply to the customer's message. It is held for a person's approval before it is sent.",
inputSchema: z.object({ text: z.string().trim().min(1).describe("The plain-text reply.") }),
execute: async ({ text }) => {
if (claimed) return "You already finished this message. Do not call again.";
claimed = true;
const reply =
message.channel === "whatsapp"
? {
id: message.mailboxId,
messageId: message.messageId,
channel: "whatsapp" as const,
text,
}
: { id: message.mailboxId, messageId: message.messageId, text };
try {
const receipt = await samva.mailboxes.reply(reply, { idempotencyKey: `reply:${eventId}` });
outcome.reply = { state: receipt.state, actionId: receipt.actionId };
return `Reply submitted (${receipt.state}).`;
} catch (error) {
outcome.error = error;
return "The reply could not be submitted. Do not try again.";
}
},
}),
leave_for_person: tool({
description: "Leave this message for a person to answer. Nothing is sent.",
inputSchema: z.object({ reason: z.string().trim().min(1).describe("One short sentence.") }),
execute: async ({ reason }) => {
if (claimed) return "You already finished this message. Do not call again.";
claimed = true;
outcome.leftForPerson = reason;
return "Left for a person.";
},
}),
};
};Run one turn per message
Follow the mailbox with samva.mailboxes.events and build a ToolLoopAgent for each
mailbox.message.received event. See Mailbox events for the event
stream. stopWhen ends the turn once either tool is called, or after five steps. After the turn,
the handler reads outcome.error. A replayed event is refused with MailboxSendConflictError,
which carries the first reply's receipt, so the agent has nothing left to do and the handler logs
it and moves on. 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 thrown, so the
event loop does not save its cursor past a failed reply.
import { ToolLoopAgent, hasToolCall, isStepCount } from "ai";
import {
MailboxSendConflictError,
WhatsAppServiceWindowClosedError,
} from "samva";
const outcome: TurnOutcome = {};
const agent = new ToolLoopAgent({
model: config.model,
instructions: systemPrompt(config.instructions),
tools: mailboxTools(samva, received, event.id, outcome),
stopWhen: [hasToolCall("reply_to_customer", "leave_for_person"), isStepCount(5)],
});
await agent.generate({ prompt: userTurn(received) });
if (outcome.error !== undefined) {
if (outcome.error instanceof MailboxSendConflictError) {
const { receipt } = outcome.error;
return log(`already answered ${received.messageId}: ${receipt.state}, action ${receipt.actionId}`);
}
if (outcome.error instanceof WhatsAppServiceWindowClosedError) {
return log(`skipped ${received.messageId}: WhatsApp service window closed`);
}
throw outcome.error;
}The customer's message is untrusted data. userTurn wraps it in tags, and 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 Vercel AI SDK cookbook walks through the pieces in more depth. The runnable example includes the event loop and tests.