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

  1. A real email address like assistant@agents.e2a.dev that anyone can write to.
  2. An ADK agent (Gemini Flash by default — swap in any model) that receives each email as an agent turn.
  3. 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

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

What to build next

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.