Gmail API skill for Codex
Inbox bots that reply in-thread and sync by history. Codex handles Gmail the reliable way: it decodes raw messages with the standard email parser, builds replies with threadId, In-Reply-To and References so they stay in the conversation, and drafts before sending anything. Inbox bots sync incrementally from a stored historyId, fall back to a full resync on 404, and renew Pub/Sub watches daily instead of polling the whole mailbox.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- gmail-api Installs to ~/.agents/skills/gmail-api/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install gmail-api The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project gmail-api Installs to .agents/skills/gmail-api/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/gmail-api.zipunzip -o gmail-api.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) gmail-api PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $gmail-api, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Reads, sends and organises mail through the Gmail API - search queries, parsing MIME parts and attachments, sending and replying in-thread with base64url raw messages, drafts, labels and batchModify, incremental sync with history.list, and push notifications through Cloud Pub/Sub users.watch. Use when asked to process incoming Gmail, build an inbox triage or label bot, send email from a Google account, reply to threads programmatically, or replace polling with Gmail push.
Use it when
- You need to read or search a Gmail mailbox from code
- Mail must be sent or replied to from a Google account with correct threading
- An inbox needs labelling, archiving or triage at scale
- Polling should be replaced with Pub/Sub push and history sync
Use something else when
- The mailbox is not Gmail or you only have IMAP access: use imap-email-automation
- You need auth setup or invalid_grant fixes first: use google-workspace-apis
- The mailbox is Outlook or Exchange Online: use microsoft-graph
How to check the result
Fetch the thread after a reply and confirm the same threadId, and compare a messages.list run for the sync query against the IDs your handler processed.
The mistake it prevents
Setting threadId but not In-Reply-To and References headers, so the reply shows as a new conversation for the recipient.
Done well means: mail is never sent, trashed or deleted without the user confirming the exact
messages or recipients, replies land in the right thread, bodies decode correctly for every
charset and MIME shape, and sync is incremental from a stored historyId.
Auth and scope selection live in $google-workspace-apis. Gmail-specific scope choices:
| Task | Scope | Class |
|---|---|---|
| Send only | gmail.send | Sensitive |
| Read headers and labels only | gmail.metadata | Restricted (no q search allowed) |
| Read everything | gmail.readonly | Restricted |
| Read + label + trash | gmail.modify | Restricted |
| Manage labels only | gmail.labels | Non-sensitive |
| Permanent delete | https://mail.google.com/ | Restricted; avoid |
Use userId="me" for the authenticated user (or the impersonated user under delegation).
Search and read
import base64
from email import policy
from email.parser import BytesParser
def search(gmail, q, max_ids=None):
token, seen = None, 0
while True:
resp = gmail.users().messages().list(userId="me", q=q, maxResults=500, pageToken=token).execute()
for m in resp.get("messages", []):
yield m["id"]
seen += 1
if max_ids and seen >= max_ids:
return
token = resp.get("nextPageToken")
if not token:
return
def fetch_parsed(gmail, msg_id):
raw = gmail.users().messages().get(userId="me", id=msg_id, format="raw").execute()["raw"]
return BytesParser(policy=policy.default).parsebytes(base64.urlsafe_b64decode(raw))
for mid in search(gmail, 'from:[email protected] has:attachment newer_than:30d -label:processed'):
msg = fetch_parsed(gmail, mid)
body = msg.get_body(preferencelist=("plain", "html"))
text = body.get_content() if body else ""
for part in msg.iter_attachments():
save(part.get_filename(), part.get_payload(decode=True))
format="raw" plus the standard email parser is the least error-prone way to decode bodies:
it handles nested multiparts, quoted-printable and charsets. Use format="metadata" with
metadataHeaders=["From","Subject","Date"] when you only need headers (cheaper, faster).
With format="full", large attachments arrive as attachmentId and need
users.messages.attachments.get.
Useful q operators: from:, to:, subject:, label:, -label:, has:attachment,
filename:pdf, newer_than:7d, after:2026/10/01, is:unread, in:anywhere, rfc822msgid:.
Send and reply
Sending is outward-facing. Show the user the final To/Cc/Subject/body (or a sample for bulk) and
get explicit confirmation before calling send. Prefer creating a draft when the user has not
clearly asked to send.
from email.message import EmailMessage
def build_raw(to, subject, text, html=None, reply_to_msg=None, attachments=()):
m = EmailMessage()
m["To"] = to
m["Subject"] = subject
if reply_to_msg is not None: # parsed original message
m["In-Reply-To"] = reply_to_msg["Message-ID"]
refs = reply_to_msg.get("References", "")
m["References"] = f"{refs} {reply_to_msg['Message-ID']}".strip()
m.set_content(text)
if html:
m.add_alternative(html, subtype="html")
for path, mime in attachments:
maintype, subtype = mime.split("/")
m.add_attachment(open(path, "rb").read(), maintype=maintype, subtype=subtype,
filename=path.rsplit("/", 1)[-1])
return base64.urlsafe_b64encode(m.as_bytes()).decode()
body = {"raw": build_raw("[email protected]", "Re: Invoice 4471", "Paid today, thanks.", reply_to_msg=orig),
"threadId": thread_id}
gmail.users().drafts().create(userId="me", body={"message": body}).execute() # safe default
# after confirmation:
gmail.users().messages().send(userId="me", body=body).execute()
Threading needs all three: same threadId, In-Reply-To/References headers, and a subject
matching the original (a Re: prefix is fine). Omit From unless sending as a verified
send-as alias. Daily sending caps exist (roughly 2,000 per day for Workspace users, 500 for
consumer accounts); bulk or marketing mail belongs in a dedicated email service.
Labels and bulk changes
labels = {l["name"]: l["id"] for l in gmail.users().labels().list(userId="me").execute()["labels"]}
if "processed" not in labels:
labels["processed"] = gmail.users().labels().create(
userId="me", body={"name": "processed", "labelListVisibility": "labelShow",
"messageListVisibility": "show"}).execute()["id"]
gmail.users().messages().batchModify(userId="me", body={
"ids": ids[:1000], # max 1000 per call
"addLabelIds": [labels["processed"]],
"removeLabelIds": ["UNREAD", "INBOX"], # archive + mark read
}).execute()
System labels use fixed IDs (INBOX, UNREAD, STARRED, SPAM, TRASH); user labels use
generated IDs, so resolve names once. Archiving is removing INBOX. Trash
(messages.trash) is recoverable for 30 days; messages.delete and batchDelete are
permanent and need explicit confirmation of the exact set. Label filters for the future are
users.settings.filters (a standing rule: confirm before creating).
Incremental sync with history
from googleapiclient.errors import HttpError
def sync(gmail, start_history_id):
token, latest = None, start_history_id
try:
while True:
resp = gmail.users().history().list(
userId="me", startHistoryId=start_history_id, pageToken=token,
historyTypes=["messageAdded", "labelAdded"]).execute()
for h in resp.get("history", []):
for added in h.get("messagesAdded", []):
handle(added["message"]["id"]) # idempotent by message id
latest = resp.get("historyId", latest)
token = resp.get("nextPageToken")
if not token:
return latest
except HttpError as e:
if e.resp.status == 404: # historyId too old
return full_resync()
raise
Store the returned historyId only after handling succeeds. Get an initial value from
users.getProfile or from the latest message you processed.
Push notifications (Pub/Sub)
- [ ] Create a Pub/Sub topic in the same Cloud project as the OAuth client
- [ ] Grant [email protected] the Pub/Sub Publisher role on the topic
- [ ] Create a push subscription to your HTTPS endpoint (with authentication) or a pull subscription
- [ ] Call users.watch with topicName and optional labelIds/labelFilterBehavior
- [ ] Persist the returned historyId; renew watch daily (it expires after 7 days)
gmail.users().watch(userId="me", body={
"topicName": "projects/my-project/topics/gmail-inbox",
"labelIds": ["INBOX"], "labelFilterBehavior": "include",
}).execute()
Each Pub/Sub message carries base64 JSON {"emailAddress": ..., "historyId": ...} and nothing
else. Acknowledge fast, then call history.list from your stored id. Notifications can be
delayed, duplicated or dropped, so also run a periodic history.list as a safety net. Verify the
push request’s OIDC token when using push subscriptions.
Quota
Per-user limit is about 250 quota units per second (moving average). Costs: messages.send
100, messages.get 5, messages.list 5, history.list 2, messages.modify 5. A loop of
get calls on hundreds of messages needs a small worker pool (4-8) and backoff on 429 and
403 userRateLimitExceeded.
Gotchas
- Bodies are base64url, not standard base64; padding may be missing.
snippetis HTML-escaped plain text, not the body.- HTML-only messages are common; fall back to stripping HTML when no
text/plainpart exists. messages.listreturns IDs only; budget agetper message.- Message IDs are per mailbox; use the
Message-IDheader to match across mailboxes. - Dates: use the
internalDate(ms since epoch) for ordering, not theDateheader.
Verification
- Dry-run mode prints matched message IDs, subjects and planned label changes before writing.
- After sending a reply, fetch the thread and confirm the new message has the same
threadId. - After a sync run,
messages.listwith the same query and compare with handled IDs.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Gmail API"
short_description: "Inbox bots that reply in-thread and sync by history"
default_prompt: "$gmail-api Save every PDF invoice from [email protected] in Gmail to a folder and label the emails processed" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.