How do I add email to an AI agent built with LangChain, the OpenAI Agents SDK or the Vercel AI SDK?
By Naol Ketema, founder · · Markdown
SHORT ANSWER
Give the agent its own mailbox, write four plain functions over an email API (send, reply, read a thread, wait for a reply), and wrap each one in your framework's tool helper. The functions hold all the logic and the wrappers are a few lines each, so the same email tools work in LangChain, the OpenAI Agents SDK, the Vercel AI SDK or a hand-written loop.
Email is just four tools
Every agent framework has the same idea under a different name: a tool is a function with a name, a description and an input schema that the model can choose to call. Email fits that shape well once you reduce it to the operations an agent actually needs.
- send_email: start a new conversation from the agent's own address.
- reply_to_email: answer a message inside its thread, so the recipient's client threads it.
- read_thread: load the whole conversation, oldest first, as context for the model.
- wait_for_reply: block until the next reply on a thread arrives, instead of polling.
Keep these as plain functions with zod schemas. The framework adapters then stay thin, and you can switch frameworks without touching the email code.
Step 1: the framework-free core
This uses the Agent Identity TypeScript SDK. The agent runs with an agent key, which acts as exactly one agent and reaches only its own mailbox.
import { AgentClient } from "@agentidentity/sdk";
import { z } from "zod";
const client = new AgentClient({
baseUrl: "https://api.agent-identity.dev",
apiKey: process.env.AID_AGENT_KEY!,
});
const identityId = process.env.AID_AGENT!;
export const sendSchema = z.object({
to: z.array(z.string().email()).min(1),
subject: z.string(),
text: z.string(),
});
export async function sendEmail({ to, subject, text }: z.infer<typeof sendSchema>) {
const m = await client.mail.send({ identityId, to, subject, text });
return { messageId: m.id, threadId: m.threadId, status: m.status };
}
export const replySchema = z.object({ messageId: z.string(), text: z.string() });
export async function replyToEmail({ messageId, text }: z.infer<typeof replySchema>) {
const m = await client.mail.send({ identityId, replyAll: true, inReplyToMessageId: messageId, text });
return { messageId: m.id, threadId: m.threadId };
}
export const threadSchema = z.object({ threadId: z.string() });
export async function readThread({ threadId }: z.infer<typeof threadSchema>) {
const thread = await client.mail.getThread(identityId, threadId);
return thread.messages.map((m) => ({ id: m.id, from: m.from, text: m.textBody }));
}
export async function waitForReply({ threadId }: z.infer<typeof threadSchema>) {
const { event, timedOut } = await client.events.wait({
type: "mail.received",
identityId,
filter: { threadId },
timeoutMs: 55_000,
});
return timedOut ? { timedOut: true } : { timedOut: false, messageId: event!.payload.messageId };
}Return small, plain objects. The model reads every byte you return, and a full API response wastes context.
Step 2: wrap the functions for your framework
Each framework takes a name, a description, a schema and a function. Only the field names differ. The examples show send_email; the other three tools follow the same pattern.
// Vercel AI SDK (v5)
import { tool } from "ai";
export const aiSdkTools = {
send_email: tool({
description: "Send an email from your own address.",
inputSchema: sendSchema,
execute: sendEmail,
}),
};
// OpenAI Agents SDK (JavaScript)
import { tool as openaiTool } from "@openai/agents";
export const openaiSendEmail = openaiTool({
name: "send_email",
description: "Send an email from your own address.",
parameters: sendSchema,
execute: async (input) => JSON.stringify(await sendEmail(input)),
});
// LangChain.js
import { tool as lcTool } from "@langchain/core/tools";
export const lcSendEmail = lcTool(async (input) => JSON.stringify(await sendEmail(input)), {
name: "send_email",
description: "Send an email from your own address.",
schema: sendSchema,
});Two framework details catch people out. The Vercel AI SDK renamed parameters to inputSchema in version 5, so match the version you run. The OpenAI Agents SDK uses strict schemas by default, where optional fields must be nullable rather than optional; the schemas above avoid optional fields for that reason.
Step 3: start the agent when mail arrives
Tools let the agent act on email. Something also has to wake the agent when email arrives. Subscribe to the event log and hand each new message to your framework's run function.
for await (const event of client.events.subscribe({ type: "mail.received", identityId })) {
const { threadId, messageId, from, subject } = event.payload;
await runAgent(
`New email from ${from} about "${subject}". Thread ${threadId}, message ${messageId}. ` +
"Read the thread, then decide whether to reply.",
);
}subscribe advances its cursor using each event's own timestamp, re-polls when the server's 55-second window ends, and retries failures with backoff. runAgent is whatever your framework calls a run: generateText in the Vercel AI SDK, run(agent, input) in the OpenAI Agents SDK, invoke on a LangChain agent.
Python and other languages
The SDK is TypeScript, but every call is one HTTP request with a snake_case JSON body. A Python agent built with LangChain, CrewAI or anything else can define the same four tools over the REST API: POST /v1/identities/{id}/mail/send, GET /v1/identities/{id}/mail/threads/{threadId}, and GET /v1/events/wait with type, identity_id, thread_id and timeout_ms. The generated OpenAPI document at /v1/openapi.json has every request and response schema.
Things to get right whatever the framework
- Use an idempotency key derived from the cause of the send, such as reply-<inbound message id>, when the send is triggered by an event. A retried run then cannot send the same reply twice.
- Do not write send, then wait, without since. A wait matches only events created after it starts, so a fast reply can slip between the two calls. Capture a timestamp before the send and pass it as since.
- Treat email bodies as untrusted input to the model. Keep tool permissions narrow and put a human approval step in front of anything irreversible.
- Branch on error codes, not messages. The SDK throws AidApiError with code and retryable, and AidConnectionError when the request never arrived.
If your agent only sends notifications and never reads replies, a transactional email API is enough. The receive side, threading and the event log are what make a dedicated agent mailbox worth it.
FAQ
Should each framework run get its own email address?
No. Give each agent its own address, not each run. Runs come and go, but an address is what people reply to and what a thread is attached to.
Can the model call wait_for_reply for hours?
One wait lasts at most 55 seconds. For long waits, end the run and let an event subscription or a webhook start a new run when the reply arrives.
Do I need webhooks if I use subscribe?
No. subscribe long-polls the event log from your own process. Webhooks are the alternative when you would rather be called, for example from a serverless function.
What does the model see in a thread?
getThread returns the subject and every message in order, oldest first. Map it down to sender and text before handing it to the model.
Related guides
Give your agent its own identity in a couple of minutes. Get started free → · Docs · All guides