An email AI agent polls an inbox, decides what each message needs, and acts through the Gmail API instead of just summarizing. This guide builds a support-inbox triage agent with the OpenAI Agents SDK: it reads unread mail, drafts a reply as a Gmail draft, and labels the thread needs-review so a human sends it. Stack: Python, google-api-python-client, openai-agents. Budget 45-60 minutes plus a one-time OAuth setup.
| Fact | Value |
|---|---|
| Tool | Gmail API |
| Difficulty | Intermediate |
| Time required | 45-60 min |
| Compatibility | Python 3.10+, google-api-python-client, openai-agents, a Google Workspace or Gmail account with API access enabled |
| Last updated | November 2026 |
What you'll build
You'll build a triage agent for a small support inbox. On each run it lists unread messages that don't already carry an agent-processed label, reads each one, and for anything that looks like a routine question, drafts a reply as a Gmail draft and labels the thread needs-review. It never calls Gmail's send endpoint itself; a human opens the draft, edits if needed, and hits send.
The OpenAI Agents SDK's Agent and Runner give you the loop: define instructions, hand the agent two function_tools (list_unread_support_emails and create_draft_reply), and call Runner.run once per polling cycle with a prompt like 'triage the inbox.' The agent decides how many emails to read and when a draft is warranted; your code only decides what the tools are allowed to do.
Drafting instead of sending is the real safety mechanism here, not a permission callback. It's the right default for most inbox agents: a wrong tool call costs you a draft someone deletes, not an email a customer actually received. If you need true autonomous sending later, add an approval step the way the Slack and Claude Agent SDK guides do before removing this guardrail.
Prerequisites
- A Google Cloud project with the Gmail API enabled and OAuth 2.0 credentials (Desktop app type is simplest for testing)
- An OpenAI API key (OPENAI_API_KEY)
- pip install google-api-python-client google-auth-oauthlib openai-agents
- A support inbox you're allowed to read and draft in, or a test Gmail account
Step-by-step guide
Enable the Gmail API and get OAuth credentials
In Google Cloud Console, enable the Gmail API for your project, then create an OAuth 2.0 Client ID of type Desktop app under Credentials and download it as credentials.json. The scopes you need are gmail.readonly, gmail.modify (for labels), and gmail.compose (for drafts); request the narrowest set your use case allows rather than the broad gmail scope.
1pip install google-api-python-client google-auth-oauthlib openai-agents2export OPENAI_API_KEY="sk-..."3# credentials.json downloaded from Google Cloud Console next to this scriptExpected result: A credentials.json file in your project directory and the Gmail API showing as enabled in Cloud Console.
Write the one-time auth helper
gmail_service() loads a saved token.json if one exists and refreshes it, or runs the installed-app OAuth flow the first time and saves the result. Every tool function below calls this helper to get an authorized service object; it's the only place credentials touch your code.
1from google.oauth2.credentials import Credentials2from google_auth_oauthlib.flow import InstalledAppFlow3from google.auth.transport.requests import Request4from googleapiclient.discovery import build5import os67SCOPES = ["https://www.googleapis.com/auth/gmail.modify", "https://www.googleapis.com/auth/gmail.compose"]89def gmail_service():10 creds = None11 if os.path.exists("token.json"):12 creds = Credentials.from_authorized_user_file("token.json", SCOPES)13 if not creds or not creds.valid:14 if creds and creds.expired and creds.refresh_token:15 creds.refresh(Request())16 else:17 flow = InstalledAppFlow.from_client_secrets_file("credentials.json", SCOPES)18 creds = flow.run_local_server(port=0)19 open("token.json", "w").write(creds.to_json())20 return build("gmail", "v1", credentials=creds)Expected result: Running this once opens a browser for consent; every call after that works silently from the saved token.json.
Define the read-only tool: list unread support email
list_unread_support_emails queries Gmail with is:unread -label:agent-processed so it skips anything already handled, pulls each message's snippet, sender, and subject, and returns them as a compact list. Decorate it with @function_tool from the agents package so the Agent can call it; the docstring becomes the description the model reads.
1from agents import function_tool23@function_tool4def list_unread_support_emails(max_results: int = 10) -> list[dict]:5 """List unread support emails that haven't been processed yet, newest first."""6 service = gmail_service()7 resp = service.users().messages().list(8 userId="me", q="is:unread -label:agent-processed", maxResults=max_results9 ).execute()10 out = []11 for m in resp.get("messages", []):12 msg = service.users().messages().get(userId="me", id=m["id"], format="metadata",13 metadataHeaders=["From", "Subject"]).execute()14 headers = {h["name"]: h["value"] for h in msg["payload"]["headers"]}15 out.append({"id": m["id"], "from": headers.get("From"), "subject": headers.get("Subject"),16 "snippet": msg.get("snippet")})17 return outExpected result: A list of dicts, one per unread support email, each with id, from, subject, and snippet.
Define the write tool: draft a reply and label the thread
create_draft_reply builds a reply as an EmailMessage threaded to the original message, base64-encodes it, calls drafts().create, and then adds the agent-processed and needs-review labels to the original message so it's skipped next run and surfaced for a human. This is the only tool that writes anything, and it never calls messages().send.
1import base642from email.message import EmailMessage34@function_tool5def create_draft_reply(message_id: str, to: str, subject: str, body: str) -> str:6 """Create a Gmail draft reply to message_id and label the thread needs-review. Never sends."""7 service = gmail_service()8 msg = EmailMessage()9 msg.set_content(body)10 msg["To"] = to11 msg["Subject"] = f"Re: {subject}"12 raw = base64.urlsafe_b64encode(msg.as_bytes()).decode()13 service.users().drafts().create(14 userId="me", body={"message": {"raw": raw, "threadId": message_id}}15 ).execute()16 label_ids = _ensure_labels(service, ["agent-processed", "needs-review"])17 service.users().messages().modify(18 userId="me", id=message_id, body={"addLabelIds": label_ids}19 ).execute()20 return f"Drafted a reply to {to} and labeled the thread needs-review."Expected result: A new draft appears in Gmail threaded under the original message, and the original message gains two labels.
Build the Agent and run one triage pass
Create an Agent with both tools and instructions that tell it when a draft is warranted versus when to leave a message alone (billing disputes, anything angry, anything you can't answer from the FAQ). Call Runner.run once with a simple instruction; the agent decides how many of the listed emails to read and reply to in that pass.
1from agents import Agent, Runner2import asyncio34inbox_agent = Agent(5 name="Inbox triage agent",6 instructions=(7 "You triage a support inbox. List unread emails, then for each routine question "8 "(order status, how-to, pricing) draft a short, friendly reply. Skip anything that "9 "sounds like a billing dispute, a complaint, or something you're not confident about; "10 "leave those unread for a human."11 ),12 tools=[list_unread_support_emails, create_draft_reply],13)1415async def triage_once():16 result = await Runner.run(inbox_agent, "Triage the inbox now.")17 print(result.final_output)1819if __name__ == "__main__":20 asyncio.run(triage_once())Expected result: Running the script lists the unread count, drafts replies for the routine ones, and leaves tricky messages untouched and still unread.
Run it on a schedule and harden the dedupe
Wrap triage_once() in a loop with a sleep interval, or call it from a cron job or scheduled Cloud Function for production; polling every 5-10 minutes is plenty for most support inboxes and avoids hitting Gmail's quota. Wrap every Gmail call in try/except googleapiclient.errors.HttpError so a transient API error doesn't crash the whole pass, and double-check the agent-processed label was actually applied before trusting the dedupe.
1import time2from googleapiclient.errors import HttpError34async def triage_loop(poll_seconds: int = 300):5 while True:6 try:7 await triage_once()8 except HttpError as e:9 print(f"Gmail API error, will retry next cycle: {e}")10 time.sleep(poll_seconds)Expected result: A long-running process that triages new mail every 5 minutes and survives a transient Gmail API error without crashing.
Complete working example
1"""Inbox triage agent: drafts replies to routine support email, never sends.2Run: python inbox_triage_agent.py3(pip install google-api-python-client google-auth-oauthlib openai-agents; set OPENAI_API_KEY)4"""5import asyncio6import base647import os8import time9from email.message import EmailMessage1011from agents import Agent, Runner, function_tool12from google.auth.transport.requests import Request13from google.oauth2.credentials import Credentials14from google_auth_oauthlib.flow import InstalledAppFlow15from googleapiclient.discovery import build16from googleapiclient.errors import HttpError1718SCOPES = [19 "https://www.googleapis.com/auth/gmail.modify",20 "https://www.googleapis.com/auth/gmail.compose",21]222324def gmail_service():25 creds = None26 if os.path.exists("token.json"):27 creds = Credentials.from_authorized_user_file("token.json", SCOPES)28 if not creds or not creds.valid:29 if creds and creds.expired and creds.refresh_token:30 creds.refresh(Request())31 else:32 flow = InstalledAppFlow.from_client_secrets_file("credentials.json", SCOPES)33 creds = flow.run_local_server(port=0)34 open("token.json", "w").write(creds.to_json())35 return build("gmail", "v1", credentials=creds)363738def _ensure_labels(service, names: list[str]) -> list[str]:39 existing = {l["name"]: l["id"] for l in service.users().labels().list(userId="me").execute().get("labels", [])}40 ids = []41 for name in names:42 if name in existing:43 ids.append(existing[name])44 else:45 created = service.users().labels().create(46 userId="me", body={"name": name, "labelListVisibility": "labelShow"}47 ).execute()48 ids.append(created["id"])49 return ids505152@function_tool53def list_unread_support_emails(max_results: int = 10) -> list[dict]:54 """List unread support emails that haven't been processed yet, newest first."""55 service = gmail_service()56 resp = service.users().messages().list(57 userId="me", q="is:unread -label:agent-processed", maxResults=max_results58 ).execute()59 out = []60 for m in resp.get("messages", []):61 msg = service.users().messages().get(62 userId="me", id=m["id"], format="metadata", metadataHeaders=["From", "Subject"]63 ).execute()64 headers = {h["name"]: h["value"] for h in msg["payload"]["headers"]}65 out.append({66 "id": m["id"], "from": headers.get("From"), "subject": headers.get("Subject"),67 "snippet": msg.get("snippet"),68 })69 return out707172@function_tool73def create_draft_reply(message_id: str, to: str, subject: str, body: str) -> str:74 """Create a Gmail draft reply to message_id and label the thread needs-review. Never sends."""75 service = gmail_service()76 msg = EmailMessage()77 msg.set_content(body)78 msg["To"] = to79 msg["Subject"] = f"Re: {subject}"80 raw = base64.urlsafe_b64encode(msg.as_bytes()).decode()81 service.users().drafts().create(82 userId="me", body={"message": {"raw": raw, "threadId": message_id}}83 ).execute()84 label_ids = _ensure_labels(service, ["agent-processed", "needs-review"])85 service.users().messages().modify(86 userId="me", id=message_id, body={"addLabelIds": label_ids}87 ).execute()88 return f"Drafted a reply to {to} and labeled the thread needs-review."899091inbox_agent = Agent(92 name="Inbox triage agent",93 instructions=(94 "You triage a support inbox. List unread emails, then for each routine question "95 "(order status, how-to, pricing) draft a short, friendly reply. Skip anything that "96 "sounds like a billing dispute, a complaint, or something you're not confident about; "97 "leave those unread for a human."98 ),99 tools=[list_unread_support_emails, create_draft_reply],100)101102103async def triage_once():104 result = await Runner.run(inbox_agent, "Triage the inbox now.")105 print(result.final_output)106107108async def triage_loop(poll_seconds: int = 300):109 while True:110 try:111 await triage_once()112 except HttpError as e:113 print(f"Gmail API error, will retry next cycle: {e}")114 time.sleep(poll_seconds)115116117if __name__ == "__main__":118 asyncio.run(triage_loop())119Common mistakes
Why it's a problem: Calling messages().send directly from a tool to 'save a step'
How to avoid: Draft first. The cost of a bad draft is someone deleting it; the cost of a bad send is an email your customer already has.
Why it's a problem: Querying is:unread with no label exclusion
How to avoid: Without -label:agent-processed, every poll re-reads and re-drafts for the same messages. Always exclude what you've already handled.
Why it's a problem: Using the broad gmail scope instead of gmail.modify plus gmail.compose
How to avoid: The broad scope grants send and full account access this agent doesn't need. Request only the scopes the tools actually call.
Why it's a problem: Skipping the try/except around Gmail API calls
How to avoid: A single transient 500 from Gmail's API will otherwise kill a long-running poll loop. Catch HttpError and retry next cycle.
Why it's a problem: Not re-running the OAuth consent flow after changing SCOPES
How to avoid: A saved token.json from a narrower scope won't silently gain the new permission. Delete it and re-authenticate after any scope change.
Best practices
- Default every write action to its safest form (draft, not send) unless you have a specific reason not to.
- Dedupe with a label, not an in-memory set, so restarts don't cause reprocessing.
- Write the instructions with explicit skip conditions (disputes, complaints, low confidence), not just what to do.
- Keep gmail.readonly scope on any tool that only reads, and only add gmail.compose/modify to tools that need them.
- Log which messages were drafted versus skipped on every pass so you can audit why.
- Poll on an interval that respects Gmail API quota instead of reacting to every inbox change in real time.
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I have an OpenAI Agents SDK inbox agent with list_unread_support_emails and create_draft_reply function_tools; the second only drafts, never sends (code below). Add a third tool, flag_for_escalation, that applies an urgent label instead of drafting when the email mentions a refund or legal threat. [paste complete_code]
In Cursor, open inbox_triage_agent.py and ask: 'Add a summarize_thread function_tool that returns the last 3 messages in a Gmail thread, and have the agent call it before drafting a reply to a message that's part of an existing thread.'
Frequently asked questions
Why draft instead of letting the email AI agent send replies automatically?
A draft gives a human the last look before anything reaches a customer, at the cost of one click. For a support inbox that's almost always worth it; reserve full auto-send for agents you've tested extensively on a narrow, low-risk message type.
Does this work with Outlook or other email providers?
The agent logic (Agent, Runner, function_tool) is provider-agnostic; swap the Gmail-specific tool implementations for Microsoft Graph API calls (messages, drafts endpoints) and the rest of this guide applies unchanged.
How do I avoid hitting Gmail API rate limits?
Gmail's per-user quota is generous for a single support inbox polling every few minutes, but batch your messages().get calls with Gmail's batch HTTP request feature if you ever need to read more than a few dozen messages per pass.
What if the agent drafts a reply that's factually wrong?
That's exactly what the needs-review label and draft-only behavior protect against: nothing goes out until a human reads it. If wrong drafts are common, narrow the instructions' definition of 'routine question' or add a tool that looks up real order/account data instead of guessing.
Can the same agent also handle outbound cold email instead of inbox triage?
The Agent/Runner/function_tool pattern carries over, but outbound sending is a different risk profile (deliverability, compliance, opt-outs) than replying to someone who already emailed you. Treat it as a separate agent with its own guardrails.
Can RapidDev build an email agent like this for our support team?
Yes. RapidDev builds inbox and ticketing agents on the OpenAI Agents SDK and Claude Agent SDK, including the draft-first safety pattern and label-based routing this guide uses. A free 30-minute consultation is the fastest way to scope yours.
Talk to an Expert
Our team has built 1,000+ apps. Get personalized help with your project.
Book a free consultation