A CrewAI crew can research a list of tools, open each signup page and fill in the form. Then the service sends a confirmation email, and the crew has no mailbox to receive it. Everything after that point, from the free trial to the dashboard and the API key page, stays out of reach.
This post adds the verification step to a crew using the Lumbox Python SDK. The design choice that makes it reliable is to split the step in two. Creating and deleting the inbox is deterministic, so plain Python does it before and after the crew runs. Reading the page and deciding whether the service sent a code or a link needs judgment, so that part goes to the agent.
Two tools, wrapped from the SDK
The SDK's lumbox_tools() returns plain Python functions with type hints and docstrings. CrewAI's tool() decorator can also be applied as an ordinary call, tool("name")(fn), so wrapping takes one line per function:
pip install "lumbox[crewai]"
import os
from crewai import Agent, Crew, Task
from crewai.tools import tool
from lumbox import Lumbox, lumbox_tools
API_KEY = os.environ["LUMBOX_API_KEY"]
client = Lumbox(api_key=API_KEY)
fns = lumbox_tools(api_key=API_KEY)
def never_cache(args, result):
return False
get_otp = tool("get_otp")(fns["get_otp"])
list_emails = tool("list_emails")(fns["list_emails"])
for t in (get_otp, list_emails):
t.cache_function = never_cache
The agent gets two tools, not the eight the SDK offers. get_otp waits for a code. list_emails returns what has already arrived, which is how the agent finds a verification link. The agent never gets create_inbox, because the inbox is created outside the crew.
never_cache matters because CrewAI can cache tool results by tool name and arguments. Up to at least version 1.15.2 that cache is on by default, and a cached "nothing yet" answer would come back instantly on every retry. The CrewAI email tools guide covers the caching behaviour in more detail.
The inbox lives outside the crew
signup_agent = Agent(
role="Account registration specialist",
goal="Create verified accounts on web services",
backstory=(
"After submitting a signup form, call get_otp with the inbox id and the "
"service's domain as sender. If the service verifies by link instead, call "
"list_emails with that sender and open parsed.verification_links[0] in the browser."
),
tools=[get_otp, list_emails, browser_tool],
)
signup_task = Task(
description=(
"Sign up for {service} at {signup_url} using the email address {email}. "
"The Lumbox inbox id for that address is {inbox_id}. Complete email verification."
),
expected_output="VERIFIED or FAILED, and the reason if it failed",
agent=signup_agent,
)
crew = Crew(agents=[signup_agent], tasks=[signup_task])
inbox = client.create_inbox()
try:
result = crew.kickoff(inputs={
"service": "Example",
"signup_url": "https://example.com/signup",
"email": inbox.address,
"inbox_id": inbox.id,
})
finally:
inbox.delete()
CrewAI interpolates kickoff inputs into the task description, so the agent sees a concrete address and inbox id. It cannot mix up which inbox belongs to which signup, since it never chose. The finally block deletes the inbox even when the run fails. Lumbox inboxes never expire on their own, and the free plan holds 3, so a crew that leaks inboxes starts getting HTTP 402 with PLAN_LIMIT_EXCEEDED within a few runs.
Skip the delete when the new account will need its inbox later for password resets or login codes. Store inbox.id next to the account's other details instead.
Several services in one run
To sign up for five services, create five inboxes and run the crew once per input. kickoff_for_each copies the crew for each input and runs the copies one after another. kickoff_for_each_async runs them concurrently. Either way each run has its own address, so one service's code never lands in another run's inbox.
services = [("Example A", "https://a.example.com/signup"),
("Example B", "https://b.example.com/signup")]
inboxes = [client.create_inbox() for _ in services]
try:
results = crew.kickoff_for_each(inputs=[
{"service": name, "signup_url": url, "email": ib.address, "inbox_id": ib.id}
for (name, url), ib in zip(services, inboxes)
])
finally:
for ib in inboxes:
ib.delete()
Plan limits cap how wide this goes. With 3 inboxes on the free plan, the fourth create_inbox fails before the crew starts, which is a better place to fail than halfway through a signup.
How get_otp finds the code
The tool calls GET /v1/inboxes/:id/otp. The server looks for the newest email in the inbox that was received in the last five minutes, is categorised as verification, and matches the sender substring if you gave one. If that email has codes, it answers at once with code, all_codes, from, subject, expires and email_id. If not, it checks again every second until the timeout (30 seconds by default, 120 at most) and then returns 408. The SDK raises on the 408, and CrewAI hands the error text back to the agent, which can press resend and try again.
The sender match is a plain substring of the From address, so example.com also matches noreply@mail.example.com. Pass the bare domain rather than a full address, since verification mail often comes from a subdomain or a no-reply address the agent has not seen yet.
The five minute lookback means the agent does not have to call get_otp before the email arrives. It also means a reused inbox can return a stale code: if two signups share an inbox within five minutes, the second call may get the first service's code. One inbox per signup avoids it. The expires field is filled when the email says something like "expires in 10 minutes", which tells the agent whether a slow run needs a fresh code.
When there is no code
- A verification link. Link-only emails never satisfy
get_otp.list_emailsreturns emails already received, newest first, and each carriesparsed.verification_linksandparsed.magic_links. - A letters-and-digits code. Production extraction finds digit-only codes of 4 to 8 characters in the subject and plain-text body. For a code like
K7M-2QX, the agent has to readtext_bodyfromlist_emails. The body arrives wrapped in an untrusted-content marker so the model reads it as data. - Mail flagged as spam.
list_emailshides spam by default, whileget_otpdoes not filter it. A link email that scored as spam needsinclude_spam=trueon the underlying endpoint, which the SDK tool does not expose. - A CAPTCHA. When a verification link points at a challenge page,
parsed.requires_browseris true. Lumbox does not solve CAPTCHAs, so your browser tool or a person has to.
The response fields for both endpoints are listed in the API reference, and how OTP extraction from email works explains the parsing side in more depth.