Google's Agent Development Kit (ADK) treats a plain Python function as a tool. The function name becomes the tool name, the type hints become the parameter schema, and the docstring tells the model when to call it. That makes adding email short: two async functions that call the Lumbox REST API, and an agent that uses them to get through a signup.
The ADK-specific parts are where the inbox id lives, why the functions must be async, and what the webhook route does and does not give you. The code below was run on google-adk 2.10.0 against a local mock of the Lumbox API, with a scripted model standing in for Gemini.
Two tools
pip install google-adk httpx
import os
import httpx
from google.adk.agents import Agent
from google.adk.tools import ToolContext
def lumbox() -> httpx.AsyncClient:
return 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),
)
async def create_inbox(tool_context: ToolContext) -> dict:
"""Creates a fresh email inbox for one signup.
Returns:
dict: status, and the address to type into the signup form.
"""
async with lumbox() as http:
r = await http.post("/inboxes", json={})
if r.status_code != 201:
return {"status": "error", "error_message": r.text}
d = r.json()
tool_context.state["inbox_id"] = d["id"]
return {"status": "success", "address": d["address"]}
async def wait_for_code(sender: str, tool_context: ToolContext) -> dict:
"""Waits up to 60 seconds for a verification code in this session's inbox.
Args:
sender: Domain the verification email comes from, such as github.com.
Returns:
dict: status, and the code if one arrived.
"""
inbox_id = tool_context.state.get("inbox_id")
if not inbox_id:
return {"status": "error", "error_message": "Call create_inbox first."}
async with lumbox() as http:
r = await http.get(f"/inboxes/{inbox_id}/otp", params={"from": sender, "timeout": 60})
if r.status_code == 408:
return {"status": "pending", "message": "No code arrived within 60 seconds."}
if r.status_code != 200:
return {"status": "error", "error_message": r.text}
return {"status": "success", "code": r.json()["code"]}
root_agent = Agent(
name="signup_agent",
model="gemini-2.5-flash",
instruction=(
"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. "
"If the status is pending, press the resend button and call it once more."
),
tools=[create_inbox, wait_for_code, browser_tool],
)
browser_tool stands in for whatever fills the form. Lumbox covers the mailbox half of the signup.
The inbox id goes in session state
When a tool function declares a parameter named tool_context of type ToolContext, ADK fills it in and leaves it out of the schema the model sees. tool_context.state is the session's state, so create_inbox writes the id there and wait_for_code reads it back. The model only ever handles the email address and the sender domain.
In the test run, the scripted model called wait_for_code before create_inbox on purpose. The tool answered Call create_inbox first., the next call created the inbox, and afterwards the session state held {"inbox_id": "inb_..."}. With a persistent session service, that id survives across turns, so a user can come back later in the same session and the agent still knows which inbox to check.
Why the functions are async
GET /v1/inboxes/:id/otp is a long poll. The server holds the request open, checks the inbox once a second, and answers when it finds a code or when the timeout you passed (60 here, 120 at most) runs out. A synchronous tool that blocks for 60 seconds blocks the event loop ADK runs on, and ADK's own samples warn that blocking calls force tools that could run in parallel to run one after another. With async def and httpx.AsyncClient, other work continues while the request waits.
The client's read timeout is 70 seconds because httpx defaults to 5, which would give up long before the server answers. The /otp endpoint also looks back five minutes and only returns codes from emails it categorised as verification. An email that lands while the model is still deciding to call the tool is therefore found at once, and a code from a different service in the same inbox can be picked up if you reuse inboxes. One inbox per signup avoids that.
Returning status dicts
ADK's examples return a dict with a status key, and that suits email well. A 408 is a pending result the model can act on, not an error. Other failures come back as error with the API's message, such as a 402 PLAN_LIMIT_EXCEEDED when the free plan's 3 inboxes are used up. No HTTP status raises, so one slow email never ends the session. If you prefer exceptions, ADK agents also take an on_tool_error_callback that can turn an exception into a replacement result.
Webhooks, and what they carry
A deployed ADK agent can also be told about new mail instead of waiting for it. Register an endpoint with POST /v1/webhooks and a body of {"url": "...", "events": ["email.received"]}. The response includes a whsec_ secret, shown once. Each delivery is a JSON body of {"event", "timestamp", "data"}, where data holds email_id, inbox_id, from, subject, category, otp_codes, summary and the spam fields. It does not include the body or the extracted links; fetch those with GET /v1/emails/:id.
The signature is an HMAC-SHA256 of the raw body, hex-encoded in the X-Lumbox-Signature header, keyed with the whole secret string including its prefix:
import hashlib
import hmac
def signature_ok(raw_body: bytes, header_value: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header_value)
Two limits shape how much to rely on it. Webhooks are org-wide, so your handler maps inbox_id back to the session that owns it. And inbound email.received deliveries are not retried today: if your endpoint is down for a deploy, that notification is gone. Keep wait_for_code as the path that actually gets the code, and use the webhook to wake up sessions that are not already waiting.
Links and sending
Services that verify by link never satisfy /otp, and the production parser only extracts digit-only codes of 4 to 8 characters, so a code like K7M-2QX does not show up either. A third tool covers both: GET /v1/inboxes/{inbox_id}/emails?from=<domain> returns emails already received, newest first, each with parsed.verification_links and a text_body wrapped in an untrusted-content marker.
Sending is a fourth function in the same shape: POST /v1/inboxes/{inbox_id}/send with to, subject and text, returning email_id from the 201 response. The free plan allows 3 sends a minute, so return a 429 as a pending status too.
Cleaning up
Lumbox inboxes never expire on their own. When a signup finishes and the account will not need its inbox for later password resets, read inbox_id from the session state and call DELETE /v1/inboxes/:id. Webhook, inbox and email fields are listed in the API reference, and webhooks versus polling for agents goes further into choosing between the two.