AI agents & MCP

Email for AI agents: sign-up verification without a human

An agent that can browse, fill forms and pay still stops dead at “check your email for the code”, because it has no inbox. Here is how to give it one that needs no account and no key — over MCP with a blocking wait, or as a plain tool function for LangChain, the OpenAI Agents SDK and the Vercel AI SDK — with the four-step loop that carries a sign-up through and the guardrails an unattended agent needs.

  • Intermediate
  • 21 min read
A small grey robot at a grey desk holding up a blue envelope, one hand on a keyboard, with a blue tick floating above

The wall every agent hits

Sign up for a trial, create a workspace, claim an API key, join a beta: every one of these ends at a form, and every form ends at a mailbox. A human glances at a phone. An agent has nothing to glance at — it has no address it can read, and the sensible ones stop and ask you for the code, which defeats the point of having sent an agent.

The fix is not a cleverer prompt. It is an inbox the agent can read programmatically, with no account to create first (an agent creating a mail account meets the same wall one level down) and no key to manage. A disposable mailbox is exactly that: a mailbox exists the moment mail reaches it, and reading it is one HTTP request.

Two ways to give it an inbox

The same mailbox is reachable two ways, and the choice depends on how the agent is built rather than on the mailbox:

MCP serverREST tool function
Fits whenThe agent runs in an MCP client — Claude Code, Cursor, Claude Desktop, or a framework with an MCP adapter.You write the agent in code: LangChain, the OpenAI Agents SDK, the Vercel AI SDK, or your own loop.
Waitingwait_for_message blocks server-side for up to 25 seconds and returns the message whole. Zero tokens spent while waiting.The tool function loops once a second up to its deadline. Zero tokens too — the loop is in your code, not in the model.
SetupOne URL in the client’s configuration. No code.Two functions, forty lines, one HTTP library.
What the model seesSix tools with descriptions, plus a paragraph of instructions the server sends at connection time.Whatever your tool descriptions say. The docstrings below are written to be them.

The MCP path is one line, and the per-client setup has the exact line for seven clients; an inbox an AI agent can read explains the tools in depth. The rest of this guide is the REST path, for agents you build yourself.

the MCP path, in full
{"mcpServers":{"grabmail":{"url":"https://grabmail.io/mcp"}}}

A tool function: Python, for LangChain and the OpenAI Agents SDK

Two plain functions with careful docstrings. The docstrings matter more than the code: in both frameworks they become the description the model reads to decide when to call the tool and what to do with the answer, so they say the two things an agent gets wrong — alias versus address, and what timed_out means.

inbox_tools.py
"""inbox_tools.py — two plain functions any agent framework can wrap. No key, no account."""
import secrets
import time

import requests

API = "https://grabmail.io/api/v1"


def create_inbox() -> dict:
    """Create a fresh disposable email inbox for this task.

    Returns the ADDRESS to poll and the ALIAS to give to websites. Put the alias
    into forms; never hand out the address. Nothing is created server-side.
    """
    address = f"agent-{secrets.token_hex(4)}@grabmail.io"
    r = requests.get(f"{API}/mailbox", params={"address": address}, timeout=15)
    r.raise_for_status()
    return {
        "address": address,
        "alias": r.json().get("alias"),
        "next_step": "Put the alias into the form. Then call wait_for_message with the address.",
    }


def wait_for_message(address: str, subject_contains: str = "", timeout_seconds: int = 60) -> dict:
    """Wait for an email to arrive at the address, up to timeout_seconds.

    Returns the message (from, subject, text, html) or {"status": "timed_out"}.
    On timed_out, call again — up to three times — before concluding no mail was sent.
    """
    deadline = time.monotonic() + timeout_seconds
    while time.monotonic() < deadline:
        r = requests.get(f"{API}/mailbox", params={"address": address}, timeout=15)
        if r.status_code == 429:                       # slow down, do not fail
            time.sleep(float(r.headers.get("Retry-After", 1)))
            continue
        r.raise_for_status()
        for m in r.json()["messages"]:
            if subject_contains.lower() in m["subject"].lower():
                full = requests.get(f"{API}/message/{m['id']}", params={"mailbox": address}, timeout=15)
                full.raise_for_status()
                return full.json()
        time.sleep(1)                                   # one read a second, never throttled
    return {"status": "timed_out", "hint": "Call again, up to three times, before giving up."}

Wrapping them is one call per framework. LangChain’s tool reads the docstring and the type hints; the Agents SDK’s function_tool does the same and adds the tools to an agent whose instructions repeat the loop:

LangChain
# LangChain: the docstring becomes the tool description the model reads.
from langchain_core.tools import tool

create_inbox_tool = tool(create_inbox)
wait_for_message_tool = tool(wait_for_message)
# agent = create_react_agent(model, tools=[create_inbox_tool, wait_for_message_tool, ...])
OpenAI Agents SDK
# OpenAI Agents SDK: same two functions, same docstrings.
from agents import Agent, Runner, function_tool

signup_agent = Agent(
    name="Signup agent",
    instructions=(
        "When a site needs an email address, call create_inbox once. Put the ALIAS in the form. "
        "Right after submitting, call wait_for_message with the ADDRESS and a word from the expected "
        "subject. If it returns timed_out, call it again, up to three times."
    ),
    tools=[function_tool(create_inbox), function_tool(wait_for_message)],
)

result = Runner.run_sync(signup_agent, "Sign up for a trial at https://app.example.com/signup and report the login.")
print(result.final_output)

The same tool in TypeScript, for the Vercel AI SDK

The AI SDK’s tool() takes a description, a schema and an execute; the description carries the same two sentences. Pass both tools to generateText or streamText with a maxSteps above four, because the loop is four tool calls long:

inbox-tools.ts
// inbox-tools.ts — the same two tools for the Vercel AI SDK (v5 shape: inputSchema + execute).
import { tool } from 'ai';
import { z } from 'zod';

const API = 'https://grabmail.io/api/v1';
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));

export const createInbox = tool({
  description: 'Create a fresh disposable email inbox for this task. Returns the ADDRESS to poll and the ALIAS to give to websites. Put the alias into forms; never hand out the address.',
  inputSchema: z.object({}),
  execute: async () => {
    const address = `agent-${crypto.randomUUID().slice(0, 8)}@grabmail.io`;
    const res = await fetch(`${API}/mailbox?address=${encodeURIComponent(address)}`);
    const { alias } = (await res.json()) as { alias: string | null };
    return { address, alias, next_step: 'Put the alias into the form. Then call waitForMessage with the address.' };
  },
});

export const waitForMessage = tool({
  description: 'Wait for an email to arrive at the address, up to timeoutSeconds. Returns the message (from, subject, text, html) or { status: "timed_out" }. On timed_out, call again — up to three times — before concluding no mail was sent.',
  inputSchema: z.object({
    address: z.string(),
    subjectContains: z.string().optional(),
    timeoutSeconds: z.number().int().min(5).max(120).default(60),
  }),
  execute: async ({ address, subjectContains = '', timeoutSeconds }) => {
    const deadline = Date.now() + timeoutSeconds * 1000;
    while (Date.now() < deadline) {
      const res = await fetch(`${API}/mailbox?address=${encodeURIComponent(address)}`);
      if (res.status === 429) { await sleep(Number(res.headers.get('retry-after') ?? 1) * 1000); continue; }
      if (!res.ok) throw new Error(`GET /mailbox answered ${res.status}`);
      const { messages } = (await res.json()) as { messages: { id: string; subject: string }[] };
      const hit = messages.find(m => m.subject.toLowerCase().includes(subjectContains.toLowerCase()));
      if (hit) return (await fetch(`${API}/message/${hit.id}?mailbox=${encodeURIComponent(address)}`)).json();
      await sleep(1000);
    }
    return { status: 'timed_out', hint: 'Call again, up to three times, before giving up.' };
  },
});

The shape is identical in any framework that has a notion of a tool: a description the model reads, a schema for the arguments, and a function that runs on your side. The two things to carry over are the alias sentence and the timed_out sentence; everything else is the client from the Node guide or the Python guide.

The loop, in four steps

Whatever the framework, a sign-up is the same four tool calls, and the agent should be told so in its instructions rather than left to discover it:

  1. create_inbox, once per task. Back come an address, an alias and a sentence saying which is which.
  2. The alias goes into the form. Submit.
  3. wait_for_message on the address, immediately, with subject_contains set to a word the confirmation mail will carry — “code”, “verify”, “confirm”. Not on a timer, not after other work: the mail is already on its way.
  4. The code comes out of the message the wait returned; the agent types it or follows the link. Six digits after the words the template uses — OTP codes in automated tests has the extraction rules, which apply to an agent exactly as to a test.

A sixty-second wait that returns timed_out is not a failure; it is “not yet”. The instructions should say: call again, up to three times. Three calls is over three minutes, which covers any transactional mail that was actually sent — and gives the agent three chances to notice that the form showed an error, or that it typed the alias wrong.

The alias rule

Every mailbox has two addresses. The address is the one the agent reads with; anyone who has it can open the mailbox, because there is no account and the address is the only key. The alias is a second address on a separate domain that delivers into the same mailbox and cannot be used to read it.

So the site gets the alias and the agent keeps the address. An agent that pastes the address into a form has handed the site — and anyone the site leaks it to — the ability to read every message the agent will ever receive there. The tool functions above return both with a next_step saying which goes where, and the instructions repeat it, because a rule stated twice is a rule followed.

alias@examplewhat you hand overGrabMailroutes it to the mailboxyou@examplewhat you keepRead it back and you getan empty mailbox. Always.Read it back and you getevery message.The service you signed up to only ever holds the left-hand one.
The alias is what the agent hands out and reads back an empty mailbox; the address is what it keeps and reads back every message.

Guardrails for an unattended agent

A test suite fails and stops. An agent that misreads a situation keeps going, and keeps spending. Five limits keep an email step from becoming the expensive part of a task:

One inbox per task
Never reuse an address across tasks or runs. An old message with a plausible code in it is the fastest way for an agent to confidently do the wrong thing. create_inbox costs nothing; call it every time.
A budget of waits
Three calls to wait_for_message, then stop and report. An agent that waits indefinitely for a mail that was never sent burns a worker slot and a bill.
A deadline on the whole step
Five minutes from submit to code, end to end. Past it, the right action is to tell a human what happened, not to try the form again.
Subject filtering
Always pass subject_contains. A welcome mail that arrives before the code mail is otherwise “the message”, and the agent parses six digits out of a marketing footer.
Logging the address
Write the address into the task log. Messages stay 5 days, so a human can open the mailbox afterwards and see exactly what the agent saw — the single most useful thing when a run goes wrong.

Browser agents

An agent driving a real browser — Browser Use, a Playwright MCP server, a computer-use model — is the case where the email step bites hardest, because it will meet the form before anyone planned for it. Three things make it work:

  • Give it both servers. The browser tools and the inbox tools in the same session, so that “check your email” is a tool call and not a dead end.
  • Put the loop in the system prompt. Four lines: create an inbox at the first email field; alias into the form; wait on the address right after submit; three retries on timed_out.
  • Expect refusals. A form that rejects the alias’s domain will say so in the page; the agent should read the error and stop, not try names. The honest ways round a refusal — a domain of your own, or a domain from the pool kept off the blocklists — are configuration decisions for you, not the agent.

For the agent’s own reading, the site publishes llms.txt, a plain-text map that says the same things this page does in the form a model prefers, and an OpenAPI document a code-writing agent can build the client from.

Before you let it run unattended

  • Tool descriptions that state the alias rule and what timed_out means.
  • Instructions with the four-step loop and a budget of three waits.
  • create_inbox called once per task, never reused.
  • subject_contains on every wait.
  • The address written to the task log.
  • A line forbidding anything confidential to be sent to the inbox.

That is all an agent needs. The same inbox serves a test suite by day and an agent by night, because underneath it is the same three HTTP calls — and if the site the agent signs up to refuses the public domains, a domain of your own or the pool kept off the lists plugs in without a change to the tools.

Questions

Do I need an API key for the agent?

No. The public domains take no key, no account and no header, over REST and over MCP alike. Only the paid pool of domains kept off the disposable-mail blocklists uses a bearer token, and the tool functions are otherwise identical for it.

MCP or a REST tool — which should I choose?

If the agent already lives in an MCP client, MCP: it is one line and the wait is server-side. If you are writing the agent in a framework, a tool function: it is forty lines, it works with any model, and you control the description the model reads. Both reach the same mailbox.

How much does the waiting cost in tokens?

Nothing, either way. The MCP wait blocks on the server; the REST tool loops in your code. The model spends tokens on the tool call and on reading the result, not on the sixty seconds in between — which is the whole reason not to let a model poll a mailbox itself.

Can several agents run at once?

Yes. Each task gets its own inbox and there is no session state. The limits are one read a second per address and 1200 requests a minute per client over REST, and 8 concurrent wait_for_message calls over MCP — past that the tool answers timed_out at once and the agent calls again.

Can the agent send email from the inbox?

No. The service receives only, by design — a free inbox with no account that could send would be a spam relay within the hour. An agent that has to send mail needs a sending provider and its own credentials.

What if the site refuses the alias’s domain?

Then it is on a disposable-domain blocklist, and no name in front of the @ will change that. Point a domain you own at the service (one MX record, free), or use a domain from the paid pool kept off the lists — both plug into the same tools by changing the domain constant. Why sign-up forms block disposable email explains which check refused you.

Is the inbox private to my agent?

No. Anyone who knows the address can read it, which is why the alias exists and why nothing confidential should ever be sent there. For a code that lives ten minutes that is fine; it is the one rule the agent’s instructions must state plainly.

Try it while it is fresh

An address takes one click, no account and no card. Everything in this guide works on it straight away.

Welcome back

Your inboxes and your domains, in one place.