Google ADK Integration Guide
Add a governed email mailbox to your Google ADK agents. Send, receive, and track email with built-in policy enforcement in Python or TypeScript.
Google Agent Development Kit (ADK) is a multi-agent framework for building and deploying agents with Gemini models. It supports Python and TypeScript, and integrates natively with Google Cloud services. Email is a natural communication channel for ADK agents - but without governed sending, any tool call can trigger delivery with no deduplication, no rate limiting, and no suppression checks.
This guide shows how to wire a Molted mailbox into a Google ADK agent as a typed tool. Every email the agent proposes runs through the Molted policy engine before delivery - 20+ rules evaluated in under a second. Policy runs at the infrastructure layer and cannot be overridden by the model.
Prerequisites
- A Molted account with an API key (sign up at molted.email/signup)
- A verified sending domain (see Domains)
- Google ADK installed:
- Python:
pip install google-adk - TypeScript:
npm install @google/adk
- Python:
1. Create a mailbox for your agent
Each agent (or agent team) should have its own mailbox. Log in to the portal under Mailboxes, or create one via the API:
curl -X POST https://api.molted.email/v1/me/mailboxes \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Outbound Agent",
"emailAddress": "agent@yourdomain.com"
}'Note the mailboxId from the response - you will pass it in each send request.
2. Python: Define the send tool
Google ADK tools are plain Python functions with type annotations. ADK uses the function signature and docstring to describe the tool to the model.
import os
import requests
MOLTED_API_KEY = os.environ["MOLTED_API_KEY"]
MOLTED_TENANT_ID = os.environ["MOLTED_TENANT_ID"]
MOLTED_MAILBOX_ID = os.environ["MOLTED_MAILBOX_ID"]
def send_email(to: str, subject: str, body: str, dedupe_key: str = "") -> dict:
"""Send an email to a contact through the governed mailbox.
The email is evaluated against policy rules before delivery.
Blocked sends return a reason and are not retried automatically.
Args:
to: Recipient email address.
subject: Email subject line.
body: Email body in plain text or HTML.
dedupe_key: Unique key to prevent duplicate sends within the cooldown window.
Pass a stable identifier such as "{user_id}-{event_type}".
Defaults to recipient + subject if empty.
Returns:
A dict with keys: status, requestId, blockReason (if blocked).
"""
payload = {
"tenantId": MOLTED_TENANT_ID,
"mailboxId": MOLTED_MAILBOX_ID,
"recipientEmail": to,
"templateId": "_default",
"dedupeKey": dedupe_key or f"{to}-{subject}",
"agentId": "google-adk-agent",
"payload": {
"subject": subject,
"html": body,
"text": body,
},
}
resp = requests.post(
"https://api.molted.email/v1/agent/send",
json=payload,
headers={"Authorization": f"Bearer {MOLTED_API_KEY}"},
timeout=10,
)
resp.raise_for_status()
result = resp.json()
if result["status"] == "blocked":
return {
"status": "blocked",
"blockReason": result["blockReason"],
"requestId": result["requestId"],
"message": f"Email blocked: {result['blockReason']}. Do not retry this send.",
}
return {
"status": result["status"],
"requestId": result["requestId"],
"message": f"Email queued successfully. requestId={result['requestId']}",
}ADK passes the return value back to the model as a tool result. Returning a structured dict lets the model inspect individual fields like blockReason rather than parsing a string.
3. Python: Build the agent
from google.adk.agents import Agent
from tools.send_email import send_email
outreach_agent = Agent(
name="outreach_agent",
model="gemini-2.0-flash",
description="Sends governed outreach emails on behalf of the team.",
instruction="""
You are a customer outreach agent. When sending emails:
- Always supply a dedupe_key using the format "{recipient}-{event}" to prevent duplicates.
- If status is 'blocked', report the blockReason to the user and stop - do not retry.
- 'duplicate_send': already sent recently - inform the user.
- 'suppressed_recipient': contact has opted out - do not email them again.
- 'rate_limit_exceeded': send rate reached - wait and inform the user.
- 'risk_budget_exceeded': agent risk budget exhausted for this period.
""",
tools=[send_email],
)4. Python: Run the agent
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.genai.types import Content, Part
from agent import outreach_agent
async def main():
session_service = InMemorySessionService()
runner = Runner(
agent=outreach_agent,
app_name="outreach",
session_service=session_service,
)
session = await session_service.create_session(
app_name="outreach",
user_id="user-1",
)
async for event in runner.run_async(
user_id="user-1",
session_id=session.id,
new_message=Content(
role="user",
parts=[
Part(text=(
"Send a trial welcome email to alice@example.com. "
"Subject: 'Welcome to the trial' "
"Body: 'Thanks for signing up - your 14-day trial starts now.' "
"Use dedupe key: alice-trial-welcome"
))
],
),
):
if event.is_final_response() and event.content:
for part in event.content.parts:
if part.text:
print(part.text)
if __name__ == "__main__":
import asyncio
asyncio.run(main())5. TypeScript: Define the tool and agent
import { Agent, tool } from "@google/adk/agents";
import { z } from "zod";
const MOLTED_API_KEY = process.env.MOLTED_API_KEY!;
const MOLTED_TENANT_ID = process.env.MOLTED_TENANT_ID!;
const MOLTED_MAILBOX_ID = process.env.MOLTED_MAILBOX_ID!;
const sendEmail = tool({
name: "send_email",
description:
"Send an email to a contact through the governed mailbox. " +
"The email is evaluated against policy rules before delivery. " +
"Blocked sends return a reason and should not be retried.",
inputSchema: z.object({
to: z.string().describe("Recipient email address"),
subject: z.string().describe("Email subject line"),
body: z.string().describe("Email body in plain text or HTML"),
dedupeKey: z
.string()
.optional()
.describe(
"Unique key to prevent duplicate sends. Use format: {recipient}-{event}"
),
}),
async execute({ to, subject, body, dedupeKey }) {
const resp = await fetch("https://api.molted.email/v1/agent/send", {
method: "POST",
headers: {
Authorization: `Bearer ${MOLTED_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
tenantId: MOLTED_TENANT_ID,
mailboxId: MOLTED_MAILBOX_ID,
recipientEmail: to,
templateId: "_default",
dedupeKey: dedupeKey ?? `${to}-${subject}`,
agentId: "google-adk-agent",
payload: { subject, html: body, text: body },
}),
});
const result = await resp.json();
if (result.status === "blocked") {
return {
status: "blocked",
blockReason: result.blockReason,
requestId: result.requestId,
message: `Email blocked: ${result.blockReason}. Do not retry.`,
};
}
return {
status: result.status,
requestId: result.requestId,
message: `Email queued. requestId=${result.requestId}`,
};
},
});
export const outreachAgent = new Agent({
name: "outreach_agent",
model: "gemini-2.0-flash",
description: "Sends governed outreach emails on behalf of the team.",
instruction: `
You are a customer outreach agent. When sending emails:
- Always supply a dedupeKey using the format "{recipient}-{event}" to prevent duplicates.
- If status is 'blocked', report the blockReason to the user and stop - do not retry.
- 'duplicate_send': already sent recently.
- 'suppressed_recipient': contact has opted out - do not email them again.
- 'rate_limit_exceeded': send rate reached - wait and inform the user.
`,
tools: [sendEmail],
});6. Handle policy blocks
The policy engine may block a send for several reasons. The tool surfaces these directly to the model.
| Reason | What it means |
|---|---|
duplicate_send | The same dedupeKey was used within the cooldown window |
rate_limit_exceeded | The mailbox or tenant has hit a send rate limit |
suppressed_recipient | The recipient has unsubscribed or bounced previously |
cooldown_active | A cooldown window is in effect for this recipient |
risk_budget_exceeded | The agent's risk budget for this period is exhausted |
Add explicit handling in your agent instructions so the model knows the right action for each block - especially distinguishing retryable (rate_limit_exceeded) from terminal (suppressed_recipient, duplicate_send) cases.
7. Build a multi-agent pipeline
ADK supports multi-agent orchestration. Scope mailbox access to a dedicated email sub-agent and delegate from a coordinator:
from google.adk.agents import Agent
from tools.send_email import send_email
# Email specialist - the only agent with mailbox access
email_agent = Agent(
name="email_agent",
model="gemini-2.0-flash",
description="Sends and tracks emails. Only this agent has mailbox access.",
instruction="""
You send emails for the team. Always use a dedupe_key.
Report policy blocks clearly - do not retry terminal blocks.
""",
tools=[send_email],
)
# Coordinator - routes tasks and delegates email sends
coordinator_agent = Agent(
name="coordinator",
model="gemini-2.0-flash",
description="Coordinates outreach tasks and delegates email sends to the email agent.",
instruction="""
You coordinate outreach tasks. For any email sending, delegate to the email_agent.
Do not attempt to send emails directly.
""",
sub_agents=[email_agent],
)This pattern keeps mailbox credentials scoped to email_agent. Other agents in the pipeline do not have send_email in their tool list and cannot trigger sends. Policy is enforced regardless, but scoping the tool also limits the blast radius of a misconfigured sub-agent.
8. Add a delivery status check tool
import os
import requests
MOLTED_API_KEY = os.environ["MOLTED_API_KEY"]
def check_email_status(request_id: str) -> dict:
"""Check the delivery status of a previously queued email.
Args:
request_id: The requestId returned when the email was sent.
Returns:
A dict with keys: status, provider, lastEvent.
"""
resp = requests.get(
f"https://api.molted.email/v1/agent/send/{request_id}/status",
headers={"Authorization": f"Bearer {MOLTED_API_KEY}"},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
return {
"status": data["status"],
"provider": data.get("provider", "unknown"),
"lastEvent": data.get("lastEvent", "none"),
}Add check_email_status to your agent's tools list alongside send_email. The agent can then verify delivery after queuing a send.
9. Listen for inbound replies
Use the Molted CLI to stream inbound events to your agent so it can react to replies:
molted listen --pipe "python reply_handler.py" --events "inbound.*"In reply_handler.py:
import sys
import json
import asyncio
import os
from google.adk.agents import Agent
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.genai.types import Content, Part
from tools.send_email import send_email
reply_agent = Agent(
name="reply_agent",
model="gemini-2.0-flash",
description="Handles inbound email replies.",
instruction="You handle inbound replies. Respond helpfully and concisely.",
tools=[send_email],
)
session_service = InMemorySessionService()
runner = Runner(
agent=reply_agent,
app_name="reply",
session_service=session_service,
)
async def handle_event(event: dict) -> None:
if event.get("event") != "inbound.classified":
return
data = event["data"]
if data.get("intent") == "support" and data.get("suggestedAction") == "reply":
session = await session_service.create_session(
app_name="reply",
user_id="inbound",
)
async for adk_event in runner.run_async(
user_id="inbound",
session_id=session.id,
new_message=Content(
role="user",
parts=[Part(text=f"Reply to {data['fromEmail']} who wrote: '{data['subject']}'.")],
),
):
if adk_event.is_final_response() and adk_event.content:
for part in adk_event.content.parts:
if part.text:
print(f"Reply sent: {part.text}")
for line in sys.stdin:
event_data = json.loads(line.strip())
asyncio.run(handle_event(event_data))See Reactive Agent Guide for the full event reference and daemon setup.
What happens under the hood
When your agent calls send_email, the mailbox evaluates 20+ policy rules in sequence before anything leaves:
- Suppression check - has this recipient unsubscribed or bounced?
- Deduplication - has this
dedupeKeybeen used within the cooldown window? - Cooldown - is there an active cooldown for this recipient?
- Rate limits - has the mailbox or tenant exceeded its send rate (per-minute, per-hour, per-day)?
- Risk budget - has the agent exhausted its send budget for this period?
- Consent - does the contact have a valid consent record?
If all rules pass, the email is delivered. If any rule fires, the send is blocked and an immutable decision trace records which rule triggered and why. The agent sees the blockReason immediately and can respond accordingly.
The model cannot override these checks - they run at the infrastructure layer, outside the tool's return path.
Related
- Quickstart - send your first policy-checked email
- Policy Simulation - test rules against draft sends before deploying
- Reactive Agent Guide - build agents that respond to inbound email
- Human-in-the-Loop Guide - require human approval for high-risk sends
- Agent Coordination - multi-agent leases and consensus
- Autonomy Levels - configure trust levels per mailbox
- LangChain Integration Guide
- CrewAI Integration Guide
- AutoGen Integration Guide
- OpenAI Agents SDK Integration Guide
- Pydantic AI Integration Guide
Pydantic AI Integration Guide
Add a governed email mailbox to your Pydantic AI agents. Send, receive, and track email with built-in policy enforcement and type-safe tool definitions.
Semantic Kernel Integration Guide
Add a governed email mailbox to your Semantic Kernel agents. Send, receive, and track email with built-in policy enforcement in C#, Python, or TypeScript.