Integration Guide

Vercel AI SDK

Add postal mail tools to any Vercel AI SDK project. Works with streamText, generateText, and the useChat hook. Compatible with any model provider (OpenAI, Anthropic, Google, etc.).

New mailbox.bot-issued physical receiving addresses and associated package receiving: Not live yet · Launch ETA: mid/late September. Reservations are open, but a reservation does not assign or activate an address. Assigned-PMB examples below apply only to accounts that already have receiving enabled.

Install

bash
npm install ai @ai-sdk/openai zod

Tool Definitions

Create a lib/mailbox-tools.ts file with your mail tools. Each tool uses zod for parameter validation.

typescript
// lib/mailbox-tools.ts
import { tool } from "ai";
import { z } from "zod";

const API_KEY = process.env.MAILBOX_BOT_API_KEY!;
const BASE = "https://mailbox.bot/api/v1";
const headers = { Authorization: `Bearer ${API_KEY}` };

const AGENT_ID = process.env.MAILBOX_BOT_AGENT_ID!;

// Fetch MAILBOX.md version (required for outbound mail)
async function getMdVersion(): Promise<string> {
  const res = await fetch(`${BASE}/agents/${AGENT_ID}/instructions`, { headers });
  const data = await res.json();
  return String(data.version);
}

export const listPhysicalInbound = tool({
  description: "List physical letters, flats, and parcels at the assigned PMB",
  inputSchema: z.object({}),
  execute: async () => {
    const res = await fetch(`${BASE}/inbound-items`, { headers });
    return res.ok ? JSON.stringify(await res.json()) : `Error: ${await res.text()}`;
  },
});

export const requestOpenAndScan = tool({
  description: "Request the only supported physical action after member approval",
  inputSchema: z.object({
    inboundItemId: z.string(),
    expectedVersion: z.number().int().nonnegative(),
  }),
  execute: async ({ inboundItemId, expectedVersion }) => {
    const res = await fetch(`${BASE}/inbound-items/${inboundItemId}/actions`, {
      method: "POST",
      headers: {
        ...headers,
        "Content-Type": "application/json",
        "Idempotency-Key": `open-scan-${inboundItemId}-${expectedVersion}`,
      },
      body: JSON.stringify({
        action_type: "open_and_scan",
        expected_version: expectedVersion,
      }),
    });
    return res.ok ? JSON.stringify(await res.json()) : `Error: ${await res.text()}`;
  },
});

export const getPhysicalScanResults = tool({
  description: "Get completed scans for one physical inbound item",
  inputSchema: z.object({
    inboundItemId: z.string(),
  }),
  execute: async ({ inboundItemId }) => {
    const res = await fetch(`${BASE}/inbound-items/${inboundItemId}/scans`, { headers });
    return res.ok ? JSON.stringify(await res.json()) : `Error: ${await res.text()}`;
  },
});

export const sendLetter = tool({
  description: "Print and mail a physical letter. Provide the PDF as base64.",
  inputSchema: z.object({
    recipientName: z.string(),
    recipientLine1: z.string(),
    recipientCity: z.string(),
    recipientState: z.string().length(2),
    recipientZip: z.string(),
    pdfBase64: z.string().describe("Base64-encoded PDF file content"),
    mailClass: z.enum([
      "first_class", "priority", "certified",
      "certified_return_receipt",
    ]).default("first_class"),
  }),
  execute: async (params) => {
    const form = new FormData();
    const pdf = Buffer.from(params.pdfBase64, "base64");
    form.append("document", new Blob([pdf], { type: "application/pdf" }), "letter.pdf");
    form.append("recipient_name", params.recipientName);
    form.append("recipient_line1", params.recipientLine1);
    form.append("recipient_city", params.recipientCity);
    form.append("recipient_state", params.recipientState);
    form.append("recipient_zip", params.recipientZip);
    form.append("mail_class", params.mailClass);

    const res = await fetch(`${BASE}/mail`, {
      method: "POST",
      headers: { ...headers, "X-Mailbox-MD-Version": await getMdVersion() },
      body: form,
    });
    if (!res.ok) return `Error: ${await res.text()}`;
    return `Letter queued. ID: ${(await res.json()).outbound_mail.id}`;
  },
});

Server-Side: generateText / streamText

typescript
// app/api/mail-agent/route.ts
import { streamText, stepCountIs, UIMessage, convertToModelMessages } from "ai";
import { openai } from "@ai-sdk/openai";
import {
  listPhysicalInbound, requestOpenAndScan,
  getPhysicalScanResults, sendLetter,
} from "@/lib/mailbox-tools";

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();

  const result = streamText({
    model: openai("gpt-4o"),
    system: `Keep forwarded digital/OCR context separate from physical custody.
For assigned-PMB items, request only open_and_scan after member approval,
with the current item version. Infer no other physical handling action.`,
    messages: await convertToModelMessages(messages),
    tools: { listPhysicalInbound, requestOpenAndScan,
             getPhysicalScanResults, sendLetter },
    stopWhen: stepCountIs(5), // allow multi-step tool use
  });

  return result.toUIMessageStreamResponse();
}

Client-Side: useChat Hook

typescript
// app/mail/page.tsx
"use client";
import { useState } from "react";
import { useChat } from "@ai-sdk/react";

export default function MailAssistant() {
  const [input, setInput] = useState("");
  const { messages, sendMessage } = useChat({
    api: "/api/mail-agent",
  });

  return (
    <div>
      {messages.map((m) => (
        <div key={m.id}>
          <strong>{m.role}:</strong>
          {m.parts.map((part, i) =>
            part.type === "text" ? <span key={i}>{part.text}</span> : null
          )}
        </div>
      ))}
      <form onSubmit={(e) => {
        e.preventDefault();
        sendMessage({ text: input });
        setInput("");
      }}>
        <input value={input} onChange={(e) => setInput(e.target.value)}
               placeholder="Check the mailbox..." />
      </form>
    </div>
  );
}

Webhook Route Handler

For an account that already has an assigned PMB and receiving enabled, process inbound mail events as they arrive. Add this Next.js route handler to trigger your agent automatically.

typescript
// app/api/webhooks/mailbox-bot/route.ts
import crypto from "crypto";

const WEBHOOK_SECRET = process.env.MAILBOX_BOT_WEBHOOK_SECRET!;

export async function POST(req: Request) {
  const body = await req.text();
  const sigHeader = req.headers.get("x-mailbox-signature") || "";

  // Signature format: whsk_prefix:t=<timestamp>,v1=<hmac-hex>
  const colonIdx = sigHeader.indexOf(":");
  const sigData = sigHeader.slice(colonIdx + 1);
  const params = Object.fromEntries(
    sigData.split(",").map((p) => p.split("=", 2))
  );
  const ts = params.t || "";
  const v1 = params.v1 || "";

  const expected = crypto
    .createHmac("sha256", WEBHOOK_SECRET)
    .update(`${ts}.${body}`)
    .digest("hex");

  if (!v1 || v1.length !== expected.length ||
      !crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) {
    return new Response("Invalid signature", { status: 401 });
  }

  const event = JSON.parse(body);

  if (event.event_type === "inbound.received") {
    console.info("Physical inbound received", {
      inboundItemId: event.inbound_item_id,
      instruction: "Report arrival; request only Open & Scan after member approval.",
    });
  }

  return new Response("ok");
}

API Reference