Agent Playbook, build tutorial
A walkthrough of how to build an inbox agent that takes the daily flood of email off your team: it triages each message, pulls the tasks and dates out of long threads, and drafts on-brand replies, while a person approves every send. Every code step is shown in three frameworks, Google ADK, the Claude SDK, and LangGraph, so you can build it on the stack your team already knows. The code here is the agent's core logic; standing it up in production also means adding the setup, integrations, and hardening around it.
Email is where a lot of real work arrives, and also where it gets lost. The important message waits behind a dozen newsletters, a task mentioned halfway down a thread is forgotten, and replies pile up until answering them is its own job. A shared inbox for sales or support makes it worse, because everyone sees it and no one owns it.
An inbox agent does the first pass. It sorts every message, lifts the tasks and dates into your tools, and writes a draft reply where one is needed, so your team starts from a prioritized list and a draft rather than a wall of unread mail. The one thing it never does is send on its own. For the customer side of the inbox, this pairs with the support and lead-qualification playbooks.
You do not build the agent loop from scratch. A framework gives you the moving parts, and you supply the logic: a step is a model plus an instruction plus a typed schema or a few tools. The code tabs on the build steps show the same triage, extract, and draft flow three ways.
Google ADK composes the steps with a SequentialAgent and a Runner, the Claude SDK drives a tool-use loop, and LangGraph builds an explicit graph. The mailbox intake, the action routing, and the approval guardrail are deterministic and identical in all three; only the orchestration and the model calls differ.
Every message flows through one loop: pulled in and triaged, its tasks extracted, then routed by label. A reply is drafted and queued for approval, tasks and meetings go to your tools, and the rest is archived. Guardrails for idempotency, approval, and audit wrap the whole thing.
The mailbox to manage (a shared support or sales inbox is a good start), and OAuth access to Gmail or Microsoft Graph.
A model API key (Gemini, Claude, or both), your email provider, and the task manager and calendar you want items pushed to.
Your tone and reply templates, which actions are automatic versus approved, DPDP Act 2023 handling, and which threads stay out of scope.
Install the packages and set your Gemini key. That is enough to run the runnable file below, which triages, extracts, and drafts for a sample email locally. For production you also connect, with OAuth, your Gmail or Outlook mailbox and your task and calendar tools.
# Python 3.10 or newer
python -m venv venv && source venv/bin/activate
# The runnable path below uses Google ADK + Gemini:
pip install google-adk google-genai pydantic
# Your Gemini key from aistudio.google.com/apikey:
export GEMINI_API_KEY="your-key-here" | Layer | Pick | Why |
|---|---|---|
| Mailbox connection | Gmail API, Microsoft Graph (Outlook), or IMAP | Read new messages and create drafts in the inbox your team already uses. The same thread store works across providers. |
| Triage | A model (Gemini 2.5 Flash, Claude) over the email text | Sort each message into a fixed set, needs a reply, a task, a meeting, an update, or noise, with a priority, so the inbox orders itself. The code tabs show this three ways. |
| Extraction | A model with a typed schema (structured output) | Pull the tasks, dates, and asks out of the body into structured items your task and calendar tools can use. |
| Drafting | A model with your tone and templates | Write an on-brand reply for messages that need one. The draft is for review; the agent never sends it on its own. |
| Agent framework | Google ADK, the Claude SDK, or LangGraph | Drives pull, triage, extract, draft, and queue, with a human approving every send. Pick the one your team runs. |
| Data and audit | A thread store, your task and calendar APIs, plus an audit log | Holds the triage labels, the extracted items, the drafts awaiting approval, and a record of every action the agent took. |
The triage, extract, and draft steps show the same logic in Google ADK, the Claude SDK, and LangGraph. Use the tabs to switch. The intake, routing, and approval steps are deterministic Python and identical everywhere. These are the building blocks, and they call your own systems (the mailbox, the task tool) by name; the complete file you can run today is in "Put it together" below.
Start with the connection, not the cleverness. The agent reads new messages through the Gmail API, Microsoft Graph for Outlook, or plain IMAP, and keys each one on its message id. That id makes the whole pipeline idempotent: a message is triaged once, even if the job re-runs or a webhook fires twice. Deterministic, and the same in every framework.
from googleapiclient.discovery import build
def pull_new(gmail, seen: set) -> list[dict]:
resp = gmail.users().messages().list(
userId="me", q="is:unread", maxResults=50).execute()
out = []
for m in resp.get("messages", []):
if m["id"] in seen: # already triaged
continue
seen.add(m["id"])
msg = gmail.users().messages().get(
userId="me", id=m["id"], format="full").execute()
out.append({"id": m["id"], "thread": msg["threadId"],
"text": extract_text(msg)}) # your header+body helper
return out
# Message id makes it idempotent: each email is handled once, even on a re-run. An inbox is a pile of different things, so before anything else the agent sorts each message into one label from a closed list, needs a reply, a task, a meeting, an update, a promotion, or spam, and that label decides what happens next. Classification into a fixed set keeps the output predictable. The tabs show the same triage in Gemini, Claude, and LangChain; the labels never change.
from google import genai
client = genai.Client()
LABELS = ["needs_reply", "task", "meeting", "fyi", "promotions", "spam"]
def triage(text: str) -> str:
return client.models.generate_content(
model="gemini-2.5-flash",
contents=[f"Classify this email into one of {LABELS}. "
f"Reply with the label only.\n{text}"]).text.strip() from anthropic import Anthropic
client = Anthropic()
def triage(text: str) -> str:
res = client.messages.create(model="claude-opus-4-8", max_tokens=64,
tools=[{"name": "set_label", "input_schema": {"type": "object",
"properties": {"label": {"type": "string", "enum": [
"needs_reply", "task", "meeting", "fyi", "promotions", "spam"]}},
"required": ["label"]}}],
tool_choice={"type": "tool", "name": "set_label"},
messages=[{"role": "user", "content": f"Classify this email: {text}"}])
return res.content[0].input["label"] from langchain_google_genai import ChatGoogleGenerativeAI
from pydantic import BaseModel
from typing import Literal
class Triage(BaseModel):
label: Literal["needs_reply", "task", "meeting", "fyi", "promotions", "spam"]
classifier = ChatGoogleGenerativeAI(
model="gemini-2.5-flash").with_structured_output(Triage)
def triage(text: str) -> str:
return classifier.invoke(f"Classify this email: {text}").label A lot of work hides inside email bodies, so the agent pulls it out into structured items: the things you are asked to do, the dates that matter, who wants what. Because the schema is typed, the output is data your task and calendar tools can consume, not prose someone has to re-read. The tabs show structured extraction three ways.
from google import genai
from google.genai import types
from pydantic import BaseModel
client = genai.Client()
class Item(BaseModel):
what: str; due: str | None = None; owner: str | None = None
class Extracted(BaseModel):
tasks: list[Item]; summary: str
def extract(text: str) -> Extracted:
res = client.models.generate_content(
model="gemini-2.5-flash",
contents=[f"Extract action items and a one-line summary.\n{text}"],
config=types.GenerateContentConfig(
response_mime_type="application/json", response_schema=Extracted))
return res.parsed # a typed Extracted object from anthropic import Anthropic
client = Anthropic()
SCHEMA = {"type": "object", "properties": {
"tasks": {"type": "array", "items": {"type": "object", "properties": {
"what": {"type": "string"}, "due": {"type": "string"},
"owner": {"type": "string"}}, "required": ["what"]}},
"summary": {"type": "string"}}, "required": ["tasks", "summary"]}
def extract(text: str) -> dict:
res = client.messages.create(model="claude-opus-4-8", max_tokens=1024,
tools=[{"name": "save_items", "input_schema": SCHEMA}],
tool_choice={"type": "tool", "name": "save_items"},
messages=[{"role": "user",
"content": f"Extract action items and a summary.\n{text}"}])
return res.content[0].input from langchain_google_genai import ChatGoogleGenerativeAI
from pydantic import BaseModel
class Item(BaseModel):
what: str; due: str | None = None; owner: str | None = None
class Extracted(BaseModel):
tasks: list[Item]; summary: str
extractor = ChatGoogleGenerativeAI(
model="gemini-2.5-flash").with_structured_output(Extracted)
def extract(text: str) -> Extracted:
return extractor.invoke(f"Extract action items and a summary.\n{text}") With a label in hand, the next move is a plain lookup, not another model call. A message that needs a reply goes to drafting, a meeting goes to scheduling, a task goes to your task tool, and updates, promotions, and spam are archived. An unknown label falls to a human rather than being guessed. Deterministic, and identical everywhere.
ACTION = {"needs_reply": "draft", "meeting": "schedule", "task": "create_task",
"fyi": "archive", "promotions": "archive", "spam": "archive"}
def decide(label: str) -> str:
return ACTION.get(label, "human") # anything unmapped goes to a person
# A simple, auditable map from label to action; no model call needed here. For messages that need a reply, the model writes a draft in your tone, using your templates and the thread context. This is the one place to be strict: the agent drafts, it does not send. Every outgoing message waits for a person to approve it, which is what makes it safe to point at a live inbox. The tabs show the same draft three ways.
from google import genai
client = genai.Client()
def draft_reply(text: str, tone: str = "warm and concise") -> str:
return client.models.generate_content(
model="gemini-2.5-flash",
contents=[f"Draft a {tone} reply to this email. It is for review, "
f"not to be sent automatically.\n{text}"]).text
# The return value is a draft shown to a person, never sent by the agent. from anthropic import Anthropic
client = Anthropic()
def draft_reply(text: str, tone: str = "warm and concise") -> str:
res = client.messages.create(model="claude-opus-4-8", max_tokens=600,
messages=[{"role": "user", "content":
f"Draft a {tone} reply to this email, for review only.\n{text}"}])
return res.content[0].text from langchain_google_genai import ChatGoogleGenerativeAI
llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash") # or ChatAnthropic(...)
def draft_reply(text: str, tone: str = "warm and concise") -> str:
return llm.invoke(
f"Draft a {tone} reply to this email, for review only.\n{text}").content The asks the agent pulled out should land where work actually happens. Tasks go to your task tool, meetings to your calendar, each linked back to the email it came from so nothing is orphaned. This is straight integration code against your own systems, deterministic and the same in every framework.
def push_items(email: dict, extracted) -> None:
for t in extracted.tasks:
tasks_api.create(title=t.what, due=t.due,
source=email["thread"]) # linked to the email
audit.log("items_created", email["id"], extracted.tasks)
# Tasks and meetings land in the tools you already use, traceable to the source. This is the guardrail that makes the whole thing trustworthy. Drafts do not go out; they go into an approval queue where a person reads the original and the suggested reply side by side and approves with one tap, edits, or discards. Only on approval does the agent actually send. Nothing leaves your domain without a human behind it. Deterministic, and the same in every framework.
def queue_for_approval(email: dict, draft: str) -> None:
approval_queue.add(thread=email["thread"], draft=draft) # awaits a human
def on_approval(email: dict, final_text: str) -> None:
gmail.users().messages().send(userId="me",
body=make_reply(email["thread"], final_text)).execute()
audit.log("sent", email["id"], final_text) # only after approval
# The agent drafts and queues; sending happens only when a person approves. The pieces run on each new batch of mail. ADK composes triage, extract, and draft under a SequentialAgent and a Runner, the Claude SDK runs them in a tool-use loop, and LangGraph builds an explicit triage-extract-draft graph. Whichever you pick, the same guardrails apply: the message id makes every run idempotent, the agent never sends without approval, it respects unsubscribe and opt-out, personal data is handled in line with the DPDP Act 2023, and every triage, draft, and send is written to an immutable audit log.
from google.adk.agents import SequentialAgent
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
inbox_pipeline = SequentialAgent(name="inbox_pipeline",
sub_agents=[triager, extractor, drafter]) # each an LlmAgent with the tools
runner = Runner(agent=inbox_pipeline, app_name="inbox",
session_service=InMemorySessionService())
def guard(email: dict) -> str | None:
if store.seen(email["id"]): return "already_handled" # idempotent
if unsubscribed(email): return "skip" # respect opt-out
return None
# Run on each new batch of mail via cron / Cloud Scheduler. Sends always need approval. from anthropic import Anthropic
client = Anthropic()
TOOLS = [triage_tool, extract_tool, draft_tool, queue_tool] # JSON schemas
def run_inbox(messages: list):
while True:
resp = client.messages.create(model="claude-opus-4-8", max_tokens=2048,
tools=TOOLS, messages=messages)
if resp.stop_reason != "tool_use":
return resp
messages.append({"role": "assistant", "content": resp.content})
for b in resp.content:
if b.type == "tool_use":
out = dispatch(b.name, b.input) # no send tool: drafts only
messages.append({"role": "user", "content": [
{"type": "tool_result", "tool_use_id": b.id, "content": str(out)}]}) from langgraph.graph import StateGraph, START, END
g = StateGraph(dict)
g.add_node("triage", lambda s: {"label": triage(s["text"])})
g.add_node("extract", lambda s: {"items": extract(s["text"])})
g.add_node("draft", lambda s: {"draft": draft_reply(s["text"])})
g.add_edge(START, "triage")
g.add_edge("triage", "extract")
g.add_edge("extract", "draft")
g.add_edge("draft", END)
inbox_pipeline = g.compile() # invoke per batch; drafts queue for approval
Here is a runnable file. It triages a sample email, extracts its action
items and dates, and drafts a reply, and it does not send anything. The
sample is inline text; production reads the mailbox, as in the build steps.
Save it as main.py and run python main.py.
# main.py -- run: python main.py
# A runnable reference: triages a sample email, extracts its action items, and
# drafts a reply. It does NOT send anything (sending always needs approval).
# The sample is inline text; production reads the mailbox, as in the build steps.
import asyncio
from google import genai
from google.genai import types
from google.adk.agents import LlmAgent
from google.adk.runners import InMemoryRunner
client = genai.Client() # reads GEMINI_API_KEY
# ---- a sample email (replace with a real message from the Gmail API) ----
SAMPLE_EMAIL = """From: priya@acmesteel.in
Subject: PO 4471 delivery + a quick ask
Hi, can you confirm PO 4471 ships by Friday 4 July? Also please send the
updated rate card for 2 inch pipe before our call on Wednesday 2 July at 11am."""
# -------------------------------------------------------------------------
LABELS = ["needs_reply", "task", "meeting", "fyi", "promotions", "spam"]
def triage(text: str) -> str:
"""Sort the email into one label from the fixed set."""
return client.models.generate_content(
model="gemini-2.5-flash",
contents=[f"Classify this email into one of {LABELS}. "
f"Reply with the label only.\n{text}"]).text.strip()
def extract_items(text: str) -> str:
"""Pull action items and dates as JSON."""
return client.models.generate_content(
model="gemini-2.5-flash",
contents=["Extract action items and any dates from this email as JSON "
"with keys tasks (list of {what, due}) and summary.\n" + text],
config=types.GenerateContentConfig(response_mime_type="application/json")).text
def draft_reply(text: str) -> str:
"""Draft a warm, concise reply. For review only; not sent."""
return client.models.generate_content(
model="gemini-2.5-flash",
contents=["Draft a warm, concise reply to this email. It is for review, "
"not to be sent automatically.\n" + text]).text
agent = LlmAgent(
name="inbox_agent", model="gemini-2.5-flash",
instruction=("Call triage on the email, then extract_items, then draft_reply. "
"Report the label, the action items, and the draft. Make clear "
"the draft is queued for approval and not sent."),
tools=[triage, extract_items, draft_reply])
async def main():
runner = InMemoryRunner(agent=agent, app_name="inbox")
session = await runner.session_service.create_session(app_name="inbox", user_id="demo")
msg = types.Content(role="user",
parts=[types.Part(text=f"Process this email:\n{SAMPLE_EMAIL}")])
async for event in runner.run_async(user_id="demo", session_id=session.id, new_message=msg):
if event.is_final_response():
print(event.content.parts[0].text)
if __name__ == "__main__":
asyncio.run(main()) The agent reports a label (the sample asks a question and proposes a call, so it triages as one that needs a reply), the action items it found (confirm the PO, send the rate card, the Wednesday call), and a drafted reply queued for approval. The exact wording is model-generated, so it varies run to run; sending is never automatic.
A person sees one screen: the inbox sorted by what needs doing, the message, the drafted reply, and a single approve action before anything sends.
| Approach | Best for | Effort | Cost | Control |
|---|---|---|---|---|
| Custom agent (build in-house) | You want your own triage rules, tone, and task and calendar wiring, and the data in-house | High | Build cost + API and model usage | Full |
| Inbox assistant (SaneBox, Superhuman AI, native Gmail and Outlook AI) | Personal triage and quick drafts, fast start, you accept their rules | Low | Subscription per seat | Medium |
| No-code (n8n or Make with a Gmail node) | A few simple rules, low volume, a quick pilot | Low | Cheap, but brittle as the logic grows | Low |
The defining safety choice is that the agent never sends without approval. Drafting, triaging, and creating internal tasks are low-risk and reversible; sending an external email is not, so it always waits for a person. That one rule is what makes it safe to run against a live inbox.
Email is personal data, so it is handled in line with the DPDP Act 2023, whose core obligations phase in through 2027, and you can run the pipeline in your own cloud with access scoped to the mailboxes it needs. Whole categories of mail can be kept out of scope, and every triage, draft, and send is written to an immutable audit log.
These are directional, not promises, and depend on your volume and how much of your work arrives by email. The point is the shape of the change: less time triaging, faster responses, fewer dropped tasks, steadier replies.
| Metric | Manual | Agent |
|---|---|---|
| Time in the inbox | Hours a day reading, sorting, and replying to email by hand | Triaged and drafted in advance, so the team reviews and approves instead of starting from a blank reply |
| Response time | Important messages wait behind noise and get missed | Sorted by priority the moment they arrive, so what matters surfaces first |
| Tasks lost in email | Asks buried in long threads slip through | Extracted into your task and calendar tools, traceable to the email |
| Consistency | Reply quality and tone vary with who is busy | On-brand drafts every time, with a person approving before anything sends |
Time saved depends on email volume and how much of it needs a reply. Figures are ranges that vary by team, not guarantees.
Everything you need to know about the service and how it works. Can’t find an answer? Mail us at info@galific.com
Galific designs, builds, and runs inbox agents like this on ADK, the Claude SDK, or LangGraph, wired into your Gmail or Outlook and your task and calendar tools, with a human approving every send. Or explore the ready-made versions in our agent suite.