Build an AI Agent That Triages Gmail (Labels + Drafts)
Updated 2026-10-27
A Gmail triage agent reads new mail, decides urgency, applies a label, and optionally drafts a reply for you to review. This guide extends the OAuth setup from Connect an AI agent to Gmail with modify scope for labels and keeps compose for drafts only (no send). Last tested 2026-10-27.
Docs used:
- Gmail API users.messages.list
- Gmail API users.messages.modify
- Gmail API users.drafts.create
- Gmail search operators
Prerequisites
- Gmail API enabled and
credentials.jsonfrom Connect an AI agent to Gmail - Python 3.10+ and an LLM API key
- Delete
token.jsonif you previously authorized read-only scopes so you can re-consent with modify
Steps
1. Request the right OAuth scopes
Triage needs read, label changes, and drafts:
SCOPES = [
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/gmail.modify",
"https://www.googleapis.com/auth/gmail.compose",
]
Re-run the OAuth flow from the Gmail connect guide after changing scopes.
2. Create Gmail labels once
def ensure_label(name: str) -> str:
"""Return label id, creating the user label if missing."""
labels = svc.users().labels().list(userId="me").execute().get("labels", [])
for lab in labels:
if lab["name"] == name:
return lab["id"]
created = svc.users().labels().create(
userId="me",
body={"name": name, "labelListVisibility": "labelShow", "messageListVisibility": "show"},
).execute()
return created["id"]
Create labels such as Agent/Urgent, Agent/Review, and Agent/FYI.
3. Define triage tools
def list_unread(max_results: int = 10) -> str:
res = svc.users().messages().list(
userId="me", q="is:unread in:inbox", maxResults=max_results
).execute()
lines = []
for stub in res.get("messages", []):
msg = svc.users().messages().get(
userId="me", id=stub["id"], format="metadata",
metadataHeaders=["From", "Subject", "Date"],
).execute()
h = {x["name"]: x["value"] for x in msg["payload"]["headers"]}
lines.append(f'{stub["id"]} | {h.get("From")} | {h.get("Subject")}')
return "\n".join(lines) or "no unread"
def apply_label(message_id: str, label_name: str) -> str:
label_id = ensure_label(label_name)
svc.users().messages().modify(
userId="me",
id=message_id,
body={"addLabelIds": [label_id], "removeLabelIds": ["UNREAD"]},
).execute()
return "labeled"
def draft_reply(message_id: str, body_text: str) -> str:
orig = svc.users().messages().get(userId="me", id=message_id, format="metadata").execute()
thread_id = orig["threadId"]
headers = {h["name"]: h["value"] for h in orig["payload"]["headers"]}
from_addr = headers.get("From", "")
subject = headers.get("Subject", "")
if not subject.lower().startswith("re:"):
subject = f"Re: {subject}"
import base64
from email.message import EmailMessage
em = EmailMessage()
em["To"] = from_addr
em["Subject"] = subject
em.set_content(body_text)
raw = base64.urlsafe_b64encode(em.as_bytes()).decode()
draft = svc.users().drafts().create(
userId="me",
body={"message": {"raw": raw, "threadId": thread_id}},
).execute()
return draft["id"]
Reuse gmail_service() and svc from the Gmail connect guide.
4. Prompt the agent
Use a system message that forces a fixed workflow:
You triage inbox mail. For each unread message:
1) Call list_unread.
2) For each id, decide Urgent, Review, or FYI.
3) Call apply_label with Agent/<bucket>.
4) For Urgent or Review, call draft_reply with a short professional draft.
Never send mail. Stop after one batch.
Wire the three functions as tools and cap steps (max_steps=8) per Fix: AI agent stuck in a loop.
5. Run on a schedule (optional)
Call the triage script from cron or Build an AI agent that runs on a schedule. Log failures loudly because unattended runs have no human in the loop.
6. Human review
Drafts appear in Gmail’s Drafts folder. Sending still requires you in the Gmail UI, which matches the safety model in the base Gmail connect guide.
Common errors
insufficientPermissions / 403: Token was created with readonly scopes only. Delete token.json, add gmail.modify and gmail.compose, and authorize again.
invalid_grant on refresh: Re-run the local OAuth flow; Google revoked the refresh token.
Agent labels everything Urgent: Tighten the system prompt with examples, or require draft_reply only for Agent/Urgent.
Draft missing thread: Always pass threadId from the original message when creating the draft (see step 3).
OAuth errors: Re-check test users on the Google OAuth consent screen and delete token.json after changing scopes.
FAQ
Does triage send email automatically?
No. This guide creates drafts and changes labels. Sending requires a separate gmail.send scope and explicit product approval; we do not enable send here.
Can the agent archive or delete mail?
gmail.modify can remove the INBOX label or trash messages if you add tools for those operations. This recipe only adds custom labels and marks messages read by removing UNREAD.
How is this different from the base Gmail connect guide?
The connect guide teaches OAuth and draft-only tools. This guide adds batch unread fetch, user labels, and a triage workflow prompt for a recurring inbox job.
Related
- Connect an AI agent to Gmail
- Build an AI agent that runs on a schedule
- Fix: AI agent stuck in a loop
- Connect an AI agent to Slack (notify a channel when something is Urgent)
- What is an AI agent?