Browser Use is a Python library that lets a language model drive a real browser: you describe a goal, and the model reads the page, clicks, types and navigates until it is done or runs out of steps. Give it a signup page and it fills the form without trouble. Then the site says "check your email for a code", and the agent has no mailbox to check. This post gives each Browser Use run its own Lumbox inbox and a custom tool that waits for the verification email, with the reasoning behind each choice so you can adapt it.
Why not hand it your own inbox
The quick fix is to type your own address into the task and give the agent a way to read that mailbox. It breaks in two ways. Run two signups at once and two codes land in one inbox, with nothing reliable telling you which browser session each belongs to. Every account the agent creates is also tied to your personal address, along with whatever mail those services send it afterwards. A separate inbox per run avoids both: the address exists for one task, and anything that arrives in it belongs to that task.
The script
# signup_agent.py
import asyncio
import os
import secrets
from datetime import datetime, timedelta, timezone
import httpx
from browser_use import Agent, ChatAnthropic, Tools
LUMBOX = "https://api.lumbox.co"
HEADERS = {"X-API-Key": os.environ["LUMBOX_API_KEY"]}
async def sign_up(site: str):
async with httpx.AsyncClient(base_url=LUMBOX, headers=HEADERS, timeout=130) as api:
r = await api.post("/v1/inboxes", json={"name": f"bu-{secrets.token_hex(4)}"})
r.raise_for_status()
inbox = r.json()
started = (datetime.now(timezone.utc) - timedelta(seconds=10)).isoformat()
tools = Tools()
@tools.action(
"Wait up to 90 seconds for the verification email from a sender "
"(for example 'github.com') and return the codes and links in it"
)
async def wait_for_verification_email(sender: str) -> str:
r = await api.get(
f"/v1/inboxes/{inbox['id']}/wait",
params={"from": sender.lower(), "since": started, "timeout": 90},
)
if r.status_code == 408:
return ("No email from that sender yet. Check the address you entered, "
"or use the site's resend option and call this tool again.")
r.raise_for_status()
p = r.json()["parsed"]
return (f"codes: {p['otp_codes']}\n"
f"verification links: {p['verification_links']}\n"
f"magic links: {p['magic_links']}")
agent = Agent(
task=(
f"Create an account on {site} using the email address {inbox['address']}. "
"When the site says it has sent an email, call wait_for_verification_email "
"with the site's domain, then enter the code or open the verification link."
),
llm=ChatAnthropic(model="claude-sonnet-4-0"),
tools=tools,
)
try:
return await agent.run(max_steps=40)
finally:
await api.delete(f"/v1/inboxes/{inbox['id']}")
if __name__ == "__main__":
asyncio.run(sign_up("https://example.com"))
The Browser Use pieces are the documented ones: a Tools instance, an async function registered with @tools.action whose description tells the model when to call it, and Agent(task=..., llm=..., tools=...) run with await agent.run(max_steps=...). Swap ChatAnthropic for any model class Browser Use supports. On the Lumbox side, POST /v1/inboxes returns an id and an address on trylumbox.com, and the finally block deletes the inbox and its mail whatever the run's outcome.
Why the tool looks the way it does
The wait happens on the server. GET /v1/inboxes/:id/wait holds the request open and checks the inbox once a second, returning as soon as a matching message exists or answering 408 after timeout seconds (at most 120). The model makes one tool call and gets an answer. Left to itself, a model asked to "check the email" tends to call a list tool, see nothing, and call it again, and each of those attempts costs an agent step plus an API request against the limit of 120 per minute per key.
The time window starts before the agent does. Without since, /wait only considers mail that arrives after the request begins. An agent clicks "Create account", then spends a model step reading the confirmation page before it calls the tool, and a quick email can land during that step. Fixing started ten seconds before the agent launches means nothing that arrives during the run is missed. The inbox is new, so an early window cannot pick up anything unrelated.
The model supplies only the sender. It knows which site it is signing up for, and that is the one thing the harness does not hard-code. from is a substring match on the sender address, which Lumbox stores in lowercase, hence sender.lower(). Passing the site's domain keeps any other mail out of the match.
It returns parsed fields, not the email. The result contains codes and links only. No body text reaches the model's context, so a crafted message has no text in which to plant instructions for the agent.
Links instead of codes
Many sites confirm with a link rather than a code. verification_links holds URLs containing words such as verify, confirm or activate, or a token=-style parameter, and magic_links holds passwordless login URLs. Once the tool returns a link, the agent can navigate to it in the same browser session, which matters for sites that tie the link to the session that signed up. The parser also sets requires_browser when a link looks like it leads to a CAPTCHA or challenge page. Browser Use can reach such a page, but solving the challenge is a separate problem.
Authenticator codes are a different path
If the site then asks for a code from an authenticator app, no email is involved. Browser Use handles that case itself: put the TOTP secret in the agent's sensitive_data under a key ending in bu_2fa_code, and the agent generates current 6-digit codes on demand.
Running several at once
async def main(sites: list[str]):
gate = asyncio.Semaphore(3)
async def one(site: str):
async with gate:
return await sign_up(site)
return await asyncio.gather(*(one(s) for s in sites))
Each sign_up call owns one inbox, so concurrent runs never read each other's codes. The number running at once is bounded by your plan's live inbox limit: 3 on Free, 10 on Starter, 50 on Pro and 250 on Scale, with a 402 PLAN_LIMIT_EXCEEDED past it. The semaphore above matches the Free limit. Browsers use memory and CPU too, so the machine may be the tighter bound.
Where this stops working
Code extraction uses regular expressions over the subject and plain-text body. It finds 4 to 8 digit codes near words like code or verification, and digits alone on a line. Codes containing letters, or split by a space as in "482 913", come back as an empty list, and the agent is left with only a link to try. For a site that uses such a format, you can extend the tool to return a short piece of text_body, at the cost of putting email text in front of the model. The post on OTPs with headless browsers and agents goes through that tradeoff, and the browser automation overview covers other ways to pair Lumbox with a browser.
Get a key from the quickstart (the Free plan needs no credit card), then check the inbox reference for the other /wait filters, such as subject and has_otp.