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 a cloud-mode agent (one that receives mail via HTTPS webhook, not WebSocket).
- 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>=1.4, and FastAPI. Edit .env with your three secrets when prompted.
Step 2: Register a cloud-mode agent
In the e2a dashboard, register an agent with agent_mode: cloud. You'll get an email address (e.g. assistant@agents.e2a.dev) and an API key. The webhook URL field can stay empty for now — we'll fill it in 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
Set that public URL on your agent (replace <EMAIL> and <API_KEY>; the @ in EMAIL must be URL-encoded as %40):
curl -X PUT "https://e2a.dev/api/v1/agents/assistant%40agents.e2a.dev" \
-H "Authorization: Bearer <API_KEY>" \
-H "Content-Type: application/json" \
-d '{"webhook_url":"https://abc123.ngrok.io/webhook"}'
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
email.reply(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 = email.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 after parsing the payload is:
if not email.verify_signature(HMAC_SECRET):
raise HTTPException(status_code=401, detail="bad signature")
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, body) becomes attacker-controlled. The SDK's verify_signature checks the HMAC against the body hash and rejects replays older than 5 minutes, all in one call.
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.
email.attachmentsis 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
email.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.