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 server | REST tool function | |
|---|---|---|
| Fits when | The 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. |
| Waiting | wait_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. |
| Setup | One URL in the client’s configuration. No code. | Two functions, forty lines, one HTTP library. |
| What the model sees | Six 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.
{"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 — 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: 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: 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 — 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:
create_inbox, once per task. Back come an address, an alias and a sentence saying which is which.- The alias goes into the form. Submit.
wait_for_messageon the address, immediately, withsubject_containsset 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.- 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.
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_inboxcosts 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_outmeans. - Instructions with the four-step loop and a budget of three waits.
create_inboxcalled once per task, never reused.subject_containson 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.


