The OpenAI Agents SDK turns a decorated Python function into a tool: @function_tool reads the signature and docstring and builds the JSON schema the model sees. Email is not among the tools it ships with, so an agent that has to confirm a signup has nowhere to receive the code.
This post adds create, wait-for-code and send tools in about 50 lines, using httpx against the Lumbox REST API. The Agents SDK has two features that shape the design: a run context object that the model never sees, and per-tool settings for timeouts, error messages and human approval. The tool functions below were run against openai-agents 0.22.3 and a local mock of the Lumbox API.
The tools
pip install openai-agents httpx
import os
from dataclasses import dataclass
import httpx
from agents import Agent, RunContextWrapper, Runner, function_tool
@dataclass
class EmailContext:
http: httpx.AsyncClient
inbox_id: str | None = None
@function_tool
async def create_inbox(ctx: RunContextWrapper[EmailContext]) -> str:
"""Create a fresh email inbox for this run. Returns the address to use."""
r = await ctx.context.http.post("/inboxes", json={})
r.raise_for_status()
d = r.json()
ctx.context.inbox_id = d["id"]
return d["address"]
@function_tool(timeout=75)
async def wait_for_code(ctx: RunContextWrapper[EmailContext], sender: str) -> str:
"""Wait up to 60 seconds for a verification code.
Args:
sender: Domain the email comes from, such as github.com.
"""
if not ctx.context.inbox_id:
return "Call create_inbox first."
r = await ctx.context.http.get(
f"/inboxes/{ctx.context.inbox_id}/otp", params={"from": sender, "timeout": 60}
)
if r.status_code == 408:
return "NO_CODE"
r.raise_for_status()
return r.json()["code"]
@function_tool(needs_approval=True)
async def send_email(ctx: RunContextWrapper[EmailContext], to: str, subject: str, text: str) -> str:
"""Send a plain-text email from this run's inbox. Returns the email id."""
r = await ctx.context.http.post(
f"/inboxes/{ctx.context.inbox_id}/send",
json={"to": to, "subject": subject, "text": text},
)
r.raise_for_status()
return r.json()["email_id"]
agent = Agent[EmailContext](
name="Signup agent",
instructions=(
"Before a signup, call create_inbox and use the address it returns. "
"After submitting the form, call wait_for_code with the service's domain."
),
tools=[create_inbox, wait_for_code, send_email],
)
The inbox id lives in the context, not the prompt
Any tool whose first parameter is a RunContextWrapper receives the object you passed to Runner.run(..., context=...). The SDK leaves that parameter out of the schema, so wait_for_code shows the model a single argument, sender. The inbox id never passes through the model.
That removes a class of mistakes. A model asked to repeat inb_ followed by 20 random characters can drop a character, or reuse an id from earlier in a long conversation. Here create_inbox writes the id into the context and every later tool reads it from there. The context also holds the httpx.AsyncClient, so all tools share one connection pool and one API key.
Timeouts on three layers
GET /v1/inboxes/:id/otp is a long poll. The server holds the request and checks the inbox once a second. It returns as soon as it finds a code in an email received in the last five minutes and categorised as verification, and answers 408 when the requested timeout (default 30, maximum 120) runs out. Three timeouts have to nest around that:
- The Lumbox wait: 60 seconds, passed as a query parameter.
- The httpx read timeout: 70 seconds, set on the client below. httpx defaults to 5 seconds, which would abort every wait long before the server answered.
- The SDK's per-tool
timeout=75. It only applies to async tools. When it fires, the default behaviour sends the model a message saying the tool timed out, rather than failing the run.
Get the order wrong and the wrong layer gives up first. With the SDK timeout at 30 seconds, for instance, the model would see a generic timeout message while the email was still on its way.
What the model sees when something fails
The 408 is turned into NO_CODE, a normal answer the model can act on: press "resend" in the browser, then wait again. Other failures raise. By default the SDK catches the exception and sends the model a message beginning An error occurred while running the tool. Please try again. followed by the error text. For a 401 from a bad API key, "try again" is the wrong advice. Pass failure_error_function=None on a tool to re-raise instead, or supply your own function that returns a different message for each status.
Running it
async def main():
async with httpx.AsyncClient(
base_url="https://api.lumbox.co/v1",
headers={"X-API-Key": os.environ["LUMBOX_API_KEY"]},
timeout=httpx.Timeout(10.0, read=70.0),
) as http:
ctx = EmailContext(http=http)
try:
result = await Runner.run(agent, "Create an account at https://example.com/signup", context=ctx)
while result.interruptions:
state = result.to_state()
for item in result.interruptions:
state.approve(item) # or state.reject(item) after a human looks at it
result = await Runner.run(agent, state)
print(result.final_output)
finally:
if ctx.inbox_id:
await http.delete(f"/inboxes/{ctx.inbox_id}")
needs_approval=True on send_email makes the run stop before the send and list it in result.interruptions. Nothing is sent until your code approves it and resumes from the saved state. The loop above approves everything so it stays short; in practice you would show item.arguments to a person first. A browser tool to fill the form would be one more entry in tools.
The finally block matters because Lumbox inboxes never expire. The free plan allows 3, and a script that leaks one per run gets HTTP 402 PLAN_LIMIT_EXCEEDED within a few runs. Keep the inbox instead if the account you created will need it for password resets.
The MCP route instead
The Agents SDK can also attach the hosted Lumbox MCP server, which has more than 80 tools, including create_inbox, get_otp and send_email. Filter it down and raise the session timeout:
from agents.mcp import MCPServerStreamableHttp, create_static_tool_filter
async with MCPServerStreamableHttp(
name="lumbox",
params={
"url": "https://mcp.lumbox.co/mcp",
"headers": {"Authorization": f"Bearer {os.environ['LUMBOX_API_KEY']}"},
},
client_session_timeout_seconds=90,
tool_filter=create_static_tool_filter(allowed_tool_names=["create_inbox", "get_otp"]),
) as lumbox_mcp:
agent = Agent(name="Signup agent", instructions="...", mcp_servers=[lumbox_mcp])
client_session_timeout_seconds defaults to 5 in the SDK, which is shorter than any useful wait for an email. This route has less code and no context object, so the model has to carry the inbox id between calls itself.
Parallel runs
Each Runner.run gets its own EmailContext, so each run creates and waits on its own address. Two concurrent signups cannot read each other's codes, because the codes go to different inboxes. What limits concurrency is the plan: 3 inboxes on Free, 10 on Starter, 50 on Pro. The general API limit is 120 requests a minute per key, and a 60 second long poll counts as one request, not sixty.
Sending has tighter limits than receiving. The free plan allows 3 sends a minute, and a new free account without a verified domain can send 5 in its first hour. The sending docs list the send, reply and forward fields, and long polling for email agents covers why the wait endpoint is shaped the way it is.