April 27, 2026 · 7 min read
Give your Google ADK agent an email inbox (with conversation memory)
Google's Agent Development Kit gives you a clean abstraction for multi-turn agents: define an LlmAgent, wire up a Runner, hand the runner a session_id, and ADK keeps the conversation history straight across turns. Sessions are exactly the right primitive for stateful chat.
The problem is that "stateful chat" usually means a UI you built, a WebSocket you maintain, or a Slack bot whose installation is its own project. None of that reaches the people who actually want to talk to your agent — customers replying from Gmail, vendors using Outlook, anyone who already has an inbox.
This post walks through giving an ADK agent a real email address, with HMAC-verified inbound delivery and multi-turn memory that survives across email replies. The full runnable example lives at examples/adk-cloud-webhook in the e2a repo; this post is the narrated tour.
What you'll have when you're done
- A real email address like
assistant@agents.e2a.devthat anyone can write to. - An ADK agent (Gemini Flash by default — swap in any model) that receives each email as an agent turn.
- Multi-turn memory across email replies. When a human replies to your agent's reply, ADK loads the same session — the agent remembers the prior turns without you doing any thread-tracking yourself.
The whole webhook is ~30 lines of business logic. ADK does the memory work; e2a does the email work.
Prerequisites
- Python 3.10+
- A free e2a account at e2a.dev — you'll register an agent and subscribe a webhook so it receives mail via HTTPS POST (rather than a WebSocket stream).
- A Google AI Studio API key — aistudio.google.com/apikey.
- ngrok or cloudflared for exposing the local webhook during development.
Step 1: Clone the example and install
git clone https://github.com/Mnexa-AI/e2a.git
cd e2a/examples/adk-cloud-webhook
python -m venv .venv && source .venv/bin/activate
pip install -e .
cp .env.example .env
The pyproject.toml pulls in google-adk>=1.31, e2a>=4.0, and FastAPI. Edit .env with your three secrets when prompted.
Step 2: Register the agent
In the e2a dashboard (or POST /v1/agents), register an agent. You'll get an email address (e.g. assistant@agents.e2a.dev) and an API key. There's no cloud-vs-local "mode" — whether the agent receives over a webhook or a WebSocket is just a delivery choice you make next. We'll subscribe a webhook once we have a public URL.
Step 3: Run the webhook locally
uvicorn webhook:app --host 0.0.0.0 --port 18080 --reload
Health check:
curl http://localhost:18080/health
# {"status":"ok"}
Step 4: Expose it with ngrok
ngrok http 18080
# Forwarding https://abc123.ngrok.io -> http://localhost:18080
Subscribe a webhook at that public URL (replace <API_KEY>). Webhooks are an account-level resource — one subscription can fan out to every agent you own, filtered by event type:
curl -X POST "https://api.e2a.dev/v1/webhooks" \
-H "Authorization: Bearer <API_KEY>" \
-H "Content-Type: application/json" \
-d '{"url":"https://abc123.ngrok.io/webhook","events":["email.received"]}'
The response includes a signing secret (whsec_...). Save it as E2A_WEBHOOK_SECRET — you'll verify each delivery against it below.
Step 5: Send the first email
From any inbox, send a plain message to your agent's address:
Subject: Quick question about ADK
Body: What's the difference between InMemorySessionService and DatabaseSessionService?
In the uvicorn logs you'll see something like:
INFO replying user=you@gmail.com conv=conv_a1b2c3d4e5f6 msg=msg_xyz reply_chars=412
A few seconds later, the agent's reply lands in your inbox — verified sender, threaded properly.
Step 6: Reply, and watch ADK remember
Hit reply in your mail client and ask a follow-up that depends on the prior turn — "what about persistence guarantees?", without restating the context. The webhook receives the inbound:
INFO replying user=you@gmail.com conv=conv_a1b2c3d4e5f6 msg=msg_followup reply_chars=389
Note the conv= value — same as the first turn. ADK loaded the existing session and the agent has full memory of what was just discussed. No thread parsing on your part, no In-Reply-To lookup, no hand-rolled state.
How conversation_id keeps memory across turns
This is the only non-obvious part of the whole setup. Email is stateless at the SMTP layer — every message is independent. e2a re-creates threading by carrying an opaque conversation_id through each round-trip:
First inbound (conversation_id = None — human just started a thread)
│
v
Webhook mints conv_<random>, uses it as ADK session_id
│
v
session = sessions.get_session(...) # returns None
session = sessions.create_session(..., session_id=conv_<random>)
│
v
runner.run_async(...) # ADK records turn 1
│
v
client.messages.reply(addr, msg_id, {"body": text, "conversation_id": conv_<random>})
│ e2a stamps X-E2A-Conversation-Id on the outbound
v
... human replies in their mail client ...
│
v
Inbound has conversation_id = conv_<random> ← recovered from
│ In-Reply-To / References
v
session = sessions.get_session(...) # returns the existing session
│
v
runner.run_async(...) # ADK has full prior context
The webhook does the binding in two lines:
conversation_id = msg.get("conversation_id") or f"conv_{uuid.uuid4().hex[:12]}"
session = await sessions.get_session(app_name=APP_NAME, user_id=user_id, session_id=conversation_id)
if session is None:
session = await sessions.create_session(app_name=APP_NAME, user_id=user_id, session_id=conversation_id)
That's it. Any agent framework that lets you supply your own session ID can be wired the same way; ADK's (app_name, user_id, session_id) tuple is the easiest case because the IDs are opaque strings and the get_session / create_session APIs are straightforward.
Always verify the HMAC
The first thing the webhook does — before reading anything off the payload — is verify the signature with construct_event, passing the raw request body:
from e2a.v1 import construct_event
from e2a.v1.errors import E2AWebhookSignatureError
raw = await request.body()
try:
event = construct_event(raw, request.headers["X-E2A-Signature"], E2A_WEBHOOK_SECRET)
except E2AWebhookSignatureError:
raise HTTPException(status_code=401, detail="bad signature")
msg = event.data # verified metadata (ids, sender, subject); fetch the body via client.messages.get
This isn't optional. Anyone on the internet can POST to your public webhook URL — the HMAC signature is what proves the payload came from your e2a relay and not from someone trying to inject a fake email into your agent's session. If you skip the verify, every claim on the payload (sender, recipient, message id, subject) becomes attacker-controlled — and since the agent fetches the body using the payload's message_id, a forged id could even point it at a different message. construct_event checks the HMAC over {timestamp}.{raw_body}, rejects replays older than 5 minutes, and parses the verified envelope — all in one call. Pass the raw bytes; re-serializing parsed JSON changes the whitespace and the signature won't match.
What this example deliberately doesn't show
- Persistence + multi-worker safety.
InMemorySessionServiceloses everything on restart. It also lives in one Python process — runninguvicorn ... --workers 4shards sessions per-worker. For anything beyond the demo, useDatabaseSessionService(Postgres / SQLite) — see ADK sessions docs. - Tools. The agent has no tools — just text generation. Add them in
agent.pyonce the email loop works. - Attachments. The fetched
MessageView.attachmentsare exposed by the SDK but ignored here. ADK'sContentsupports inline data parts if you want to feed PDFs or images to a vision model. - Human-in-the-loop. The agent replies immediately. If you want approval before each outbound, enable HITL on the agent and the
client.messages.reply(...)call returns 202 with the message held for review.
What to build next
- Swap to your own domain. Register a custom domain so mail arrives at
assistant@yourcompany.cominstead of the sharedagents.e2a.dev. - Add tools to the ADK agent. Calendar lookup, CRM queries, document retrieval — anything the agent should be able to do mid-thread. ADK's tool calling will weave them into the same session memory.
- Move sessions to Postgres. ADK's
DatabaseSessionServiceis a one-line swap and survives restarts + multi-worker deployments. - Run the whole thing in production. The webhook is a regular FastAPI app; deploy to Cloud Run, Fly, Railway, or anywhere else that runs HTTP. Use a secrets manager for the three env vars and you're done.
The full runnable example, with smoke-testable signature verification and the type hints to make it easy to swap in your own agent, is at examples/adk-cloud-webhook. PRs welcome — particularly for DatabaseSessionService recipes, attachment handling, and other framework integrations.