Scans Gmail (via the claude.ai Gmail connector) for status signals on open tracked applications - interview invites, assessments, offers, rejections - and proposes them as a sourced batch the user must approve before anything is written to job_search_tracker.csv or outcome.md. Never proposes hired/offer_declined (user's real-world decision), routes conflicting signals to manual /outcome, appends-only to Notes, idempotent by message ID, read-only against the mailbox, and degrades to one message + clean exit without the connector (the /notion-sync precedent). State is gitignored personal data. By @chenyuan99, who raised the connector-dependency tension himself and restructured classification and writing into separate approval-gated steps.
13 KiB
/gmail-sync - Sync Application Status from Gmail
You are scanning the user's Gmail for status signals on tracked job applications (interview invites, assessment links, offers, rejections) and, once approved, writing the detected changes into job_search_tracker.csv and documents/applications/<company>_<role>/outcome.md - the same two places /outcome writes to, in the same schema.
Unlike /outcome (which asks the user what happened), /gmail-sync classifies real emails on its own - but it never writes on its own. Every classified change is presented as a batch before anything touches the tracker or outcome.md, and only proceeds once the user approves it (approving the whole batch at once is fine; writing first and flagging it after is not). Because a wrong write silently corrupts application history that /setup later calibrates from, every proposed change must cite its source email and every uncertain case must be surfaced instead of guessed. Never treat this command's job as "notice something in an inbox" - it is "propose a correct, sourced line for a permanent record, and write it only once the user says yes."
Follow these steps in order.
Step 0: Prerequisites
Confirm the Gmail MCP tools (mcp__claude_ai_Gmail__*) are available. If not, tell the user to connect the Gmail integration (claude.ai Settings → Connectors → Gmail) and stop - do not attempt this via Bash, IMAP, or any other channel.
Step 1: Parse Input
$ARGUMENTS may contain:
- Nothing → default lookback (see Step 3)
- A company name, e.g.
/gmail-sync acme→ scope the search to that one tracked application since <YYYY-MM-DD>→ override the lookback start date for this run only (does not change the persisted state file)
Step 2: Load State
- Read
job_search_tracker.csv. If it does not exist, tell the user there is nothing to sync against yet (suggest/outcomeor/applyfirst) and stop. Do not create it here -/gmail-syncnever originates new applications, only updates existing ones. - Read
gmail_sync/state.json(create if missing:{"last_sync": null, "processed_message_ids": []}). - Build the set of open applications: tracker rows whose
statusis not a final value (hired,rejected,no response,offer declined,withdrawn). For each, derive its archive folderdocuments/applications/<company>_<role>/(lowercase, underscores - same convention as/outcome) and check whetheroutcome.mdexists there. - If
$ARGUMENTSnamed a company, filter this set to the matching row(s) (case-insensitive). No match → tell the user and stop, do not guess.
Step 3: Build the Search Query
Lookback window: since <date> argument if given, else state.last_sync if set, else newer_than:30d.
- Call
list_labelsand look for a user label whose name suggests job-search email (e.g. contains "job", "application", "career" case-insensitively). Note itsidif found. - Normalize each open application's company name for matching later (lowercase; strip
inc,inc.,llc,ltd,a/s,corp,corporation,group; strip punctuation; collapse whitespace). - Build a Gmail query combining (with
ORgroups via{}):label:<id>if a job-search label was found- A quoted-name OR-group of the open applications' company names, e.g.
{"Acme Corp" "BigCo"} - A sender-domain OR-group of common ATS platforms:
{from:greenhouse.io from:lever.co from:myworkday.com from:ashbyhq.com from:smartrecruiters.com from:icims.com from:bamboohr.com} - The lookback bound, e.g.
newer_than:30dorafter:2026/06/15 in:inbox(skip sent/drafts - status signals come from what employers send you, not what you sent them)
Example: newer_than:30d in:inbox ({"Acme Corp" "BigCo"} OR {from:greenhouse.io from:lever.co from:myworkday.com from:ashbyhq.com})
- Call
search_threadswithview: THREAD_VIEW_MINIMAL,pageSize: 50, paginating viapageTokenuntil exhausted or results are clearly outside the relevant window.
Step 4: Filter to New Messages
For each returned thread, inspect its messages' IDs against state.processed_message_ids. Skip a thread entirely if every message in it is already processed. For threads with unprocessed messages, call get_thread with messageFormat: FULL_CONTENT to get full bodies - classification in Step 5 must never be based on the snippet/subject alone, since snippets truncate the exact phrase that distinguishes "we'd like to schedule a call" from "thanks for applying."
Step 5: Classify Each Unprocessed Message
For each new message, first try to match it to one open application: compare the normalized sender domain / display name / subject / body against the normalized company names from Step 3. No confident match (company genuinely absent, or ambiguous between two tracked companies) → do not propose a write; record it in the Step 6 summary as "unmatched" and move to the next message.
For a matched message, classify by content (require the signal phrase in the subject or the first few lines - a company name appearing only deep in a forwarded thread or newsletter footer is not a signal):
| Signal | Example phrasing | Tracker status |
outcome.md action |
|---|---|---|---|
| Application ack | "we've received your application" | (no change) | (no change - not a status signal, just noise) |
| OA / assessment | "online assessment", "coding challenge", "complete your assessment", HackerRank/Codility links | interview |
Tick nearest matching stage checkbox (or add a Notes line if no checkbox fits - assessments aren't always a listed stage) |
| Interview invite/scheduled | "schedule a call", "phone screen", "technical interview", "next round", "onsite", "final round" | interview |
Tick the matching stage checkbox with the email's date |
| Offer extended | "pleased to offer", "extend an offer", "offer letter" | offer |
Tick "Offer received" checkbox. Never propose hired or offer_declined from an email - accepting or declining is the user's decision, not something to infer. Flag prominently in the Step 6 summary as needing the user's decision, separate from the plain approve/skip table. |
| Rejection | "moving forward with other candidates", "not selected", "unable to proceed", "decided not to continue" | rejected |
Set Status: rejected, Date resolved: to the email's date |
Conflict rule: if the classified signal contradicts the application's current final-ness (e.g. a "moving forward" email arrives for a company whose tracker row briefly shows a rejected-adjacent recent write already, or a rejection arrives after an offer was already proposed this run) - do not propose overwriting it. Record it as a conflict in Step 6 for manual /outcome resolution instead.
Step 6: Present Proposed Updates
Nothing has been written yet. Present every classified change from Step 5 as a single batch, so the user can review the full picture before anything touches the tracker or outcome.md:
## Gmail Sync - Proposed Updates - YYYY-MM-DD
Scanned N threads (M new messages) since <lookback date>.
### Proposed Changes (reply "approve all", or list which to skip, e.g. "skip 2")
| # | Company | Role | Signal | Current -> Proposed Status | Source Email (date) |
|---|---|---|---|---|---|
| 1 | ... | ... | Interview invite | applied -> interview | "Subject line" (2026-07-10) |
| 2 | ... | ... | Offer extended | interview -> offer | "Subject line" (2026-07-12) |
### Needs Manual Review (conflicting signal - not proposed, use /outcome)
- **<Company>** - <what conflicted and why it wasn't proposed>
### Unmatched Emails (no change proposed)
- "<subject>" from <sender> - looked job-related but couldn't be confidently linked to a tracked application.
### Stale Applications (30+ days, no activity)
- **<Company>** - last activity YYYY-MM-DD, still `<status>`.
If the Proposed Changes table would be empty, say so briefly and skip straight to Step 8 (Update State) - there is nothing to approve. Offers still land in the Proposed Changes table (the tracker moves to offer); it's only hired/offer_declined that are never proposed.
Step 7: Wait for Approval
Stop here and wait for the user's reply. Do not write anything from this run's classification before an explicit response arrives.
- "approve all" / "yes" / equivalent → every row in the Proposed Changes table proceeds to Step 7a.
- A partial response, e.g. "approve 1, skip 2" or "just the interview one" → only the specified rows proceed.
- "no" / decline / no changes wanted → no rows proceed; go straight to Step 8 (Update State).
Approving the whole batch in one reply is expected UX - the requirement is that the reply happens first, not that the user approves row by row.
Step 7a: Write Approved Updates
For every row the user approved:
- Tracker (
job_search_tracker.csv): update the matched row'sstatuscolumn per the Step 5 table, and append tonotes:<date> gmail-sync: <signal> ("<email subject>"). Never restructure the CSV, reorder rows, or touch unrelated rows - same rule/outcomefollows. outcome.md: tick the relevant stage checkbox (adding the date in parentheses) or updateStatus/Date resolvedper the table. Append a dated entry to## Notes, never overwrite existing Notes history:YYYY-MM-DD (via /gmail-sync): <one-line summary of what the email said>. Source: "<subject>" from <sender>, <email date>.- If no archive folder/
outcome.mdexists yet for a matched application (it was added to the tracker outside/apply//outcome), create the folder and a minimaloutcome.mdfollowing the exact format indocuments/README.md, same as/outcomewould.
Rows the user skipped are left untouched - no tracker write, no outcome.md write - but their message IDs are still marked processed in Step 8, so the same email isn't re-proposed every run.
Step 8: Update State
Add every message ID processed this run - approved, skipped, unmatched, or filtered as noise - to gmail_sync/state.json's processed_message_ids, and set last_sync to today's date. This makes re-running idempotent - the same email never produces a duplicate proposal, tracker note, or Notes entry.
Step 9: Staleness Check
For open applications with no matching activity found this run, check the tracker's date column and the most recent dated Notes entry in their outcome.md. If the most recent of those is 30+ days old, flag the application as "needs follow-up" in the closing summary below. This is surfaced only - never write anything for staleness.
Step 10: Present Closing Summary
Confirm what actually happened, distinct from the Step 6 proposal:
## Gmail Sync - Done - YYYY-MM-DD
### Written
| Company | Role | Signal | Tracker Status | Source Email |
|---|---|---|---|---|
| ... | ... | Interview invite | applied -> interview | "Subject line", 2026-07-10 |
### Skipped (not written)
- **<Company>** - <signal> declined by user.
### Offers Requiring Your Decision
- **<Company>** - offer written 2026-07-12 ("<subject>"). Tracker set to `offer`; run `/outcome <company>` to record accept/decline once you decide.
### Stale Applications (30+ days, no activity)
- **<Company>** - last activity YYYY-MM-DD, still `<status>`.
If nothing was proposed this run, a brief note is enough instead of an empty summary.
If this run pushed the count of applications with a final outcome.md status to 3+ (or resolved a second application sharing a pattern), suggest the same /setup Path A calibration handoff /outcome suggests - do not duplicate that logic, just point the user there.
Important Rules
- Classify from full email bodies, never snippets. A status-changing proposal requires having actually fetched and read the message via
get_thread/get_message. - Nothing is written before the user approves the Step 6 batch. Approving everything in one reply is fine UX; writing first and flagging it after is not.
- Never propose
hiredoroffer_declined. Those require the user's real-world decision;/gmail-syncstops at proposingofferand flags it. - A conflicting signal against an already-final or already-written status is a manual-review flag, not a proposed overwrite. When in doubt, don't propose it - surface it.
- Append-only to
outcome.mdNotes, same as/outcome. Never rewrite or delete existing history. - Idempotent by message ID. Re-running must never re-propose, or duplicate a tracker note or Notes entry for, the same email.
- Never fabricate a match. If the company can't be confidently identified from the email, it goes in "Unmatched," not a guess.
- Read-only against Gmail itself. This command reads and classifies; it does not label, archive, or delete anything in the user's mailbox.
- All state is personal data.
gmail_sync/state.json,job_search_tracker.csv, anddocuments/applications/**are gitignored - never suggest committing them.