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>=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

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.