Skip to Content

OpenAI Agents SDK

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

This guide gives an OpenAI Agents 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 @openai/agents samva zod

The two tools

reply_to_customer submits a reply and leave_for_person sends nothing. Both are built for one message inside mailboxTools, so the model supplies only the words. 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 thrown and stops the turn.

tools.ts
import { tool } from "@openai/agents";
import { MailboxSendConflictError, WhatsAppServiceWindowClosedError } from "samva";
import { z } from "zod";

// Set before any await: a model can call both tools in one turn, and the calls run together.
let claimed = false;

const replyToCustomer = tool({
  name: "reply_to_customer",
  description:
    "Submit a reply to the customer's message. A person at the business approves it before it is sent.",
  parameters: z.object({ text: z.string().min(1).describe("The plain-text reply.") }),
  // A failed request stops the turn instead of going back to the model as text.
  errorFunction: null,
  execute: 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}` });
      return `Submitted: ${receipt.state}.`;
    } catch (error) {
      if (error instanceof MailboxSendConflictError) {
        log(
          `already answered ${received.messageId}: ${error.receipt.state}, action ${error.receipt.actionId}`,
        );
        return "Already answered.";
      }
      if (error instanceof WhatsAppServiceWindowClosedError) {
        log(`skipped ${received.messageId}: WhatsApp service window closed`);
        return "Not sent: the WhatsApp service window is closed.";
      }
      throw error;
    }
  },
});

const leaveForPerson = tool({
  name: "leave_for_person",
  description:
    "Send nothing and leave this message for a person. Use it when the business instructions do not cover the question or the message is not a customer asking for help.",
  parameters: z.object({
    reason: z.string().min(1).describe("One short sentence for the person."),
  }),
  execute: async ({ reason }) => {
    if (claimed) return "You already finished this message. Do not call again.";
    claimed = true;
    log(`left ${received.messageId} for a person: ${reason}`);
    return "Left for a person.";
  },
});

Run one turn per message

Follow the mailbox with samva.mailboxes.events and run one agent for each mailbox.message.received event. See Mailbox events for the event stream.

agent.ts
import { Agent, Runner } from "@openai/agents";

const runner = new Runner({ traceIncludeSensitiveData: false });

for await (const event of samva.mailboxes.events({ cursor, mailboxIds: [mailboxId] })) {
  if (event.type === "mailbox.message.received") {
    const received = parseReceivedMessage(event.data);
    if (received === undefined) throw new Error(`Malformed event ${event.id}`);

    const agent = new Agent({
      name: "Mailbox agent",
      instructions: systemPrompt(instructions),
      model: "gpt-5.6-sol",
      modelSettings: { reasoning: { effort: "low" }, parallelToolCalls: false },
      tools: mailboxTools(samva, received, event.id, log),
      // The tool's result is the end of the turn, so the model is not called again to summarize it.
      toolUseBehavior: "stop_on_first_tool",
    });

    await runner.run(agent, userTurn(received), { maxTurns: 2 });
  }
  await saveCursor(event.cursor);
}

The customer's message goes to the model inside tags, and the system prompt tells it to answer the message and never to follow instructions inside it. traceIncludeSensitiveData: false keeps the message text out of the SDK's traces.

Answer WhatsApp from the same mailbox

A WhatsApp message arrives on the same event stream, 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 OpenAI Agents SDK cookbook walks through the pieces in more depth. The runnable example includes the event loop and tests.

Related documentation

On this page