One of the most common points where LangChain agents break down in production is email verification. The agent can navigate a signup form, fill in details, click submit — and then it hits a wall. It cannot receive the OTP or click the magic link that the service sends back. This guide shows you how to close that gap by wrapping AgentMailr as a native LangChain tool.
What We Are Building
A set of three LangChain tools that give any agent full email capabilities: creating a provisioned inbox, waiting for and extracting OTPs, and fetching the raw body of any incoming email. Once wrapped as tools, your agent can use them like any other action in a chain or agent executor.
Installation
npm install agentmailr langchain @langchain/openai
Wrapping AgentMailr as LangChain Tools
LangChain's DynamicTool lets you wrap any async function as a tool. Each tool needs a name, description (used by the LLM to decide when to call it), and a function.
import { DynamicTool } from "@langchain/core/tools";
import AgentMailr from "agentmailr";
const client = new AgentMailr({ apiKey: process.env.AGENTMAILR_API_KEY });
// Store inbox state across tool calls in the same run
let activeInboxId: string | null = null;
export const createInboxTool = new DynamicTool({
name: "create_email_inbox",
description:
"Creates a temporary email inbox for this agent run. " +
"Returns the email address to use on signup forms. " +
"Call this before navigating to any signup page.",
func: async () => {
const inbox = await client.inboxes.create();
activeInboxId = inbox.id;
return `Created inbox: ${inbox.address}`;
},
});
export const waitForOTPTool = new DynamicTool({
name: "wait_for_otp",
description:
"Waits for an OTP or verification code to arrive in the active inbox. " +
"Call this immediately after submitting a form that triggers an email. " +
"Returns the extracted numeric code as a string.",
func: async () => {
if (!activeInboxId) return "Error: no inbox created yet. Call create_email_inbox first.";
const { otp } = await client.messages.waitForOTP({
inboxId: activeInboxId,
timeout: 45_000,
});
return otp;
},
});
export const getLatestEmailTool = new DynamicTool({
name: "get_latest_email",
description:
"Fetches the full body of the most recent email in the active inbox. " +
"Use this to extract magic links or read confirmation email content.",
func: async () => {
if (!activeInboxId) return "Error: no inbox created yet.";
const messages = await client.messages.list({ inboxId: activeInboxId });
if (!messages.length) return "No emails received yet.";
const latest = messages[0];
return `From: ${latest.from}
Subject: ${latest.subject}
${latest.body}`;
},
});
Connecting the Tools to an Agent
Now wire these tools into a LangChain agent. Here is a complete example using the OpenAI functions agent, which is well-suited for structured tool use:
import { ChatOpenAI } from "@langchain/openai";
import { createOpenAIFunctionsAgent, AgentExecutor } from "langchain/agents";
import { pull } from "langchain/hub";
import { createInboxTool, waitForOTPTool, getLatestEmailTool } from "./tools/email";
const tools = [createInboxTool, waitForOTPTool, getLatestEmailTool];
const llm = new ChatOpenAI({ model: "gpt-4o", temperature: 0 });
const prompt = await pull("hwchase17/openai-functions-agent");
const agent = await createOpenAIFunctionsAgent({ llm, tools, prompt });
const executor = new AgentExecutor({ agent, tools, verbose: true });
const result = await executor.invoke({
input:
"Sign up for a free account at https://example.com/signup. " +
"Use the email inbox tool to get an email address. " +
"After submitting the form, wait for the OTP and enter it to complete verification.",
});
console.log(result.output);
Using with LangGraph
If you are using LangGraph for stateful multi-step agents, the same tools work without modification. You can store the inbox ID in the graph state so it persists across nodes:
import { StateGraph, Annotation } from "@langchain/langgraph";
import AgentMailr from "agentmailr";
const client = new AgentMailr({ apiKey: process.env.AGENTMAILR_API_KEY });
const StateAnnotation = Annotation.Root({
inboxId: Annotation<string>(),
inboxAddress: Annotation<string>(),
otp: Annotation<string>(),
});
const provisionInbox = async (state: typeof StateAnnotation.State) => {
const inbox = await client.inboxes.create();
return { inboxId: inbox.id, inboxAddress: inbox.address };
};
const collectOTP = async (state: typeof StateAnnotation.State) => {
const { otp } = await client.messages.waitForOTP({
inboxId: state.inboxId,
timeout: 30_000,
});
return { otp };
};
const graph = new StateGraph(StateAnnotation)
.addNode("provision_inbox", provisionInbox)
.addNode("collect_otp", collectOTP)
.addEdge("__start__", "provision_inbox")
.addEdge("provision_inbox", "collect_otp")
.compile();
Tips for Production Use
- One inbox per agent run: Never share an inbox between concurrent runs. AgentMailr inboxes are cheap to create — provision one per workflow execution.
- Set your timeout higher than you think you need: Some services take 15-20 seconds to deliver OTP emails. A 45-second timeout is a safe default for most services.
- Delete inboxes after use: Call client.inboxes.delete(inboxId) at the end of your workflow. This keeps your account clean and prevents stale inboxes from accumulating.
- Use custom domains for higher trust: Some services reject OTPs to disposable email domains. AgentMailr supports custom domains so your agents receive mail at your own domain.