Skip to main content
RapidDev - Software Development Agency
build-ai-agent

How to Build an Email AI Agent

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.

What you'll learn

  • Authenticate against the Gmail API and read unread mail with a search query instead of pulling the whole inbox
  • Give an OpenAI Agents SDK agent a function_tool for reading mail and a separate one for drafting replies
  • Default a write action to the safest version of itself (a draft, not a send) instead of gating it with extra approval machinery
  • Avoid reprocessing the same email twice with a label-based dedupe check
Book a free consultation
4.9Clutch rating ⭐
1,000+Happy partners
20+Countries served
200+Team members
Intermediate10 min read45-60 minPython 3.10+, google-api-python-client, openai-agents, a Google Workspace or Gmail account with API access enabledLast updated November 2026RapidDev Engineering Team
TL;DR

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.

Quick facts about this guide
FactValue
ToolGmail API
DifficultyIntermediate
Time required45-60 min
CompatibilityPython 3.10+, google-api-python-client, openai-agents, a Google Workspace or Gmail account with API access enabled
Last updatedNovember 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

1

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.

typescript
1pip install google-api-python-client google-auth-oauthlib openai-agents
2export OPENAI_API_KEY="sk-..."
3# credentials.json downloaded from Google Cloud Console next to this script

Expected result: A credentials.json file in your project directory and the Gmail API showing as enabled in Cloud Console.

2

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.

typescript
1from google.oauth2.credentials import Credentials
2from google_auth_oauthlib.flow import InstalledAppFlow
3from google.auth.transport.requests import Request
4from googleapiclient.discovery import build
5import os
6
7SCOPES = ["https://www.googleapis.com/auth/gmail.modify", "https://www.googleapis.com/auth/gmail.compose"]
8
9def gmail_service():
10 creds = None
11 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.

3

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.

typescript
1from agents import function_tool
2
3@function_tool
4def 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_results
9 ).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 out

Expected result: A list of dicts, one per unread support email, each with id, from, subject, and snippet.

4

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.

typescript
1import base64
2from email.message import EmailMessage
3
4@function_tool
5def 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"] = to
11 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.

5

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.

typescript
1from agents import Agent, Runner
2import asyncio
3
4inbox_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)
14
15async def triage_once():
16 result = await Runner.run(inbox_agent, "Triage the inbox now.")
17 print(result.final_output)
18
19if __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.

6

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.

typescript
1import time
2from googleapiclient.errors import HttpError
3
4async 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

inbox_triage_agent.py
1"""Inbox triage agent: drafts replies to routine support email, never sends.
2Run: python inbox_triage_agent.py
3(pip install google-api-python-client google-auth-oauthlib openai-agents; set OPENAI_API_KEY)
4"""
5import asyncio
6import base64
7import os
8import time
9from email.message import EmailMessage
10
11from agents import Agent, Runner, function_tool
12from google.auth.transport.requests import Request
13from google.oauth2.credentials import Credentials
14from google_auth_oauthlib.flow import InstalledAppFlow
15from googleapiclient.discovery import build
16from googleapiclient.errors import HttpError
17
18SCOPES = [
19 "https://www.googleapis.com/auth/gmail.modify",
20 "https://www.googleapis.com/auth/gmail.compose",
21]
22
23
24def gmail_service():
25 creds = None
26 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)
36
37
38def _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 ids
50
51
52@function_tool
53def 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_results
58 ).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 out
70
71
72@function_tool
73def 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"] = to
79 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."
89
90
91inbox_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)
101
102
103async def triage_once():
104 result = await Runner.run(inbox_agent, "Triage the inbox now.")
105 print(result.final_output)
106
107
108async 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)
115
116
117if __name__ == "__main__":
118 asyncio.run(triage_loop())
119

Common 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.

ChatGPT Prompt

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]

Build AI Agents Prompt

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.

RapidDev

Talk to an Expert

Our team has built 1,000+ apps. Get personalized help with your project.

Book a free consultation
Matt Graham

Written by

Matt Graham · CEO & Founder, RapidDev

1,000+ client projects delivered. Columbia University & Harvard Business School alumnus, U.S. Navy veteran. About the author →

Learning is great. Shipping is faster with help.

Our engineers have built 1,000+ apps on your platform and the tools around it. If your project needs to be live sooner than your learning curve allows — book a free consultation.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.