A LangChain agent with a browser tool can fill in a signup form and press submit. The next step is a six-digit code sitting in an inbox the agent does not have, and without one the run stops there.
The Lumbox Python SDK ships a factory for this. create_langchain_tools() returns ready-made LangChain tools backed by the Lumbox REST API. This post wires them into a LangChain v1 agent, then covers the three things that go wrong once it runs for real: a timeout that ends the whole run, the different lookback rules of the two waiting tools, and inboxes that pile up until the plan limit refuses the next one.
What the factory gives you
Install the SDK with its LangChain extra. The extra pulls in langchain-core; add langchain itself for create_agent.
pip install "lumbox[langchain]" langchain
create_langchain_tools(api_key) takes the eight plain functions from lumbox_tools() and wraps each one with StructuredTool.from_function. The argument schema comes from the type hints and the description from the docstring. Every tool calls one endpoint and returns a JSON string:
| Tool | Endpoint | What comes back |
|---|---|---|
create_inbox | POST /v1/inboxes | id, address, name, status |
get_otp | GET /v1/inboxes/:id/otp | code, all_codes, from, subject, expires, email_id |
wait_for_email | GET /v1/inboxes/:id/wait | the full email with parsed codes and links |
list_emails | GET /v1/inboxes/:id/emails | emails already received, newest first |
read_email | GET /v1/inboxes/:id/emails/:emailId | one email plus attachments |
search_emails | GET /v1/emails?q= | keyword matches across inboxes |
add_domain | POST /v1/domains | DNS records to create |
verify_domain | POST /v1/domains/:id/verify | verification status |
The set has no send, reply or delete tool, because the Python SDK has no send methods at all. If your agent needs to write email, the companion post on writing your own LangChain email tools builds those with httpx against the REST API.
Wiring the tools into an agent
A signup agent needs three of the eight. Handing it add_domain and verify_domain only gives the model more ways to pick the wrong tool, so filter by name before you pass the list on:
import os
from lumbox import create_langchain_tools
from langchain.agents import create_agent
all_tools = create_langchain_tools(api_key=os.environ["LUMBOX_API_KEY"])
email_tools = [t for t in all_tools
if t.name in {"create_inbox", "get_otp", "list_emails", "wait_for_email"}]
agent = create_agent(
model="anthropic:claude-sonnet-4-6",
tools=email_tools + [browser_tool],
system_prompt=(
"Call create_inbox once before a signup and use the returned address. "
"After submitting the form, call get_otp with the inbox id and the "
"sender domain. If the service sends a link instead of a code, call "
"list_emails with that sender first, then wait_for_email if nothing is there."
),
middleware=[email_errors_to_model],
)
result = agent.invoke({
"messages": [{"role": "user", "content": "Create an account at https://example.com/signup"}]
})
browser_tool stands in for whatever drives the page, such as a Playwright tool or an MCP browser. Lumbox only handles the mailbox side. email_errors_to_model is defined in the next section, and the run does not survive its first slow email without it.
A timeout ends the run unless you catch it
Both waiting endpoints are long polls. The server checks for a matching email once a second until the timeout you pass (default 30 seconds, clamped to 1 to 120) runs out, then answers HTTP 408. The SDK raises LumboxError on any non-2xx status, so a code that takes 31 seconds to arrive becomes an exception inside the tool.
LangChain's default tool error handling only turns invalid-argument errors into a message for the model. Anything else is re-raised. Against a local mock of the API with no middleware, agent.invoke raised LumboxError: API error 408 and the run was over. The fix is a small wrap_tool_call middleware that hands the error back to the model as a ToolMessage:
from langchain.agents.middleware import wrap_tool_call
from langchain.messages import ToolMessage
@wrap_tool_call
def email_errors_to_model(request, handler):
try:
return handler(request)
except Exception as e:
return ToolMessage(
content=f"{request.tool_call['name']} failed: {e}",
tool_call_id=request.tool_call["id"],
)
With it in place the same mock run finished, and the model saw get_otp failed: API error 408: {'error': 'No OTP found', 'timeout': 1}. It can then call the tool again with a longer timeout or ask the browser to press "resend". The SDK client uses a 130 second HTTP timeout, so any value up to the 120 second server maximum completes before the client gives up.
The two waiting tools look back differently
This part decides whether the agent finds the email at all.
get_otp looks backwards. By default it considers emails received in the last five minutes, categorised as verification, and returns the codes from the newest one. An agent that submits the form, spends 20 seconds reasoning, and only then calls get_otp still gets the code, because the email is inside that window.
wait_for_email only looks forward. Its since defaults to the moment the request starts, and the tool does not expose since. If the email landed before the call, the tool waits for a second one that never comes and times out. That is why the prompt above sends link-style verifications to list_emails first: listing has no time filter, returns full emails newest first, and each one carries parsed.verification_links.
The five minute window has its own trap. Reuse one inbox for two signups within five minutes and the second get_otp call can return the first service's code straight away, since that email is still the newest verification in the window. Passing sender (a substring match on the From address) avoids it, and so does creating a new inbox per signup.
Two extraction limits are worth knowing. The production parser finds digit-only codes of 4 to 8 characters in the subject and plain-text body. An alphanumeric code like K7M-2QX will not show up in code, and a link-only email never satisfies get_otp, so it waits out the full timeout. In both cases the email is still readable through list_emails or read_email, with the body wrapped in an untrusted-content marker so the model treats it as data.
Inboxes do not clean themselves up
Lumbox inboxes never expire. Only a delete removes them, and there is no delete tool in this set. The free plan allows 3 inboxes, so an agent that calls create_inbox once per run will get HTTP 402 with code: "PLAN_LIMIT_EXCEEDED" by its fourth run at the latest.
Deletion belongs in your code, not the model's hands. Parse the inbox id out of the create_inbox tool message after the run and delete it with the plain SDK client:
import json
from lumbox import Lumbox
client = Lumbox(api_key=os.environ["LUMBOX_API_KEY"])
for msg in result["messages"]:
if msg.type == "tool" and msg.name == "create_inbox":
client.get_inbox(json.loads(msg.content)["id"]).delete()
If the account you just created needs to receive mail later, such as password resets, keep the inbox and store its id next to the credentials instead.
What this setup does not cover
It does not solve CAPTCHAs. When a verification link points at a challenge page, parsed.requires_browser is true and your browser tool has to deal with it. It also sends nothing, as covered above. The endpoints behind every tool are listed in the API reference, and the long-poll mechanics are covered in more depth in long polling for email agents.
A free key, which needs no card and allows 3 inboxes, is enough to run the example above; the quickstart covers getting one.