Skip to content
AI Agent How-To

Home ›Build an agent that does X

Build an AI Agent That Triages Gmail (Labels + Drafts)

Intermediate ~7 min read Last tested October 2026

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:

Prerequisites

  • Gmail API enabled and credentials.json from Connect an AI agent to Gmail
  • Python 3.10+ and an LLM API key
  • Delete token.json if 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.