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.