Skip to Content

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

npm install ai @ai-sdk/anthropic samva zod

The 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.

tools.ts
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.

agent.ts
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.

Related documentation

On this page