CarlyEmail docs

Vercel AI SDK

Attach CarlyEmail's MCP tools to a ToolLoopAgent and invoke it from a signed incoming-email route.

The Vercel AI SDK can consume remote MCP tools directly. CarlyEmail publishes the schemas; a ToolLoopAgent runs the model/tool loop; and a route handler wakes it when new mail arrives.

1. Install and create an inbox

npm install ai @ai-sdk/openai @ai-sdk/mcp svix
npx carlyemail signup --human-email you@example.com --username assistant
npx carlyemail verify 123456
# .env.local
OPENAI_API_KEY=sk-...
CARLYEMAIL_API_KEY=ce_us_...
CARLYEMAIL_INBOX=assistant@carlyemail.com
CARLYEMAIL_WEBHOOK_SECRET=whsec_...

2. Add the email tools

TypeScript

// lib/email-agent.ts
import { createMCPClient } from "@ai-sdk/mcp";
import { openai } from "@ai-sdk/openai";
import { ToolLoopAgent } from "ai";

const carlyemail = await createMCPClient({
  transport: {
    type: "http",
    url: "https://api.carlyemail.com/mcp",
    headers: {
      Authorization: `Bearer ${process.env.CARLYEMAIL_API_KEY}`,
    },
  },
});

export const emailAgent = new ToolLoopAgent({
  model: openai("gpt-5-mini"),
  instructions: `You manage ${process.env.CARLYEMAIL_INBOX}.
Read the message and thread before acting. Reply in the existing thread.
Draft rather than send when intent, recipient, or authority is ambiguous.`,
  tools: await carlyemail.tools(),
});

No hand-written Zod wrappers are required. When CarlyEmail adds or corrects a tool schema, the agent discovers it from the server. Filter the returned tool object before giving it to the agent when you want a smaller client-visible surface; use an inbox-scoped API key for the actual permission boundary.

3. Invoke it from incoming email

TypeScript

// app/api/carlyemail/route.ts
import { Webhook } from "svix";
import { emailAgent } from "@/lib/email-agent";

type EmailEvent = {
  event_type: string;
  event_id: string;
  message?: {
    inbox_id: string;
    message_id: string;
    thread_id: string;
    from: string;
    subject?: string;
    text?: string;
  };
};

export async function POST(request: Request) {
  const raw = await request.text();
  let event: EmailEvent;

  try {
    event = new Webhook(process.env.CARLYEMAIL_WEBHOOK_SECRET!).verify(
      raw,
      Object.fromEntries(request.headers),
    ) as EmailEvent;
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }

  if (event.event_type !== "message.received" || !event.message) {
    return new Response(null, { status: 204 });
  }

  await emailAgent.generate({
    prompt: `Handle this newly received email. Use CarlyEmail's reply tool if a
reply is appropriate.\n\n${JSON.stringify(event.message)}`,
  });

  return new Response(null, { status: 204 });
}

Register the deployed route:

npx carlyemail webhook https://your-app.vercel.app/api/carlyemail \
  --events message.received

Save the printed secret as CARLYEMAIL_WEBHOOK_SECRET. The route reads the raw body before verification; parsing and re-serializing JSON would invalidate the signature. If the agent run throws, the route returns a 5xx so CarlyEmail retries.

The email event includes thread_id. Pass it to your persistence layer or workflow state when the agent needs private memory between turns. CarlyEmail independently preserves the real email thread when the agent calls a reply tool.

Warning

Email bodies and attachments are untrusted model input. Deduplicate by event_id, scope the key to one inbox, and start with draft_create but without message_send if a person should approve replies.

See also