in:inbox matches only messages currently in the Inbox, so it silently
excluded archived mail and everything routed past the inbox by a
label-and-archive filter - exactly the mail matched by the job-search
label Step 3.1 hunts for. The stated intent ("skip sent/drafts") is what
the negative operators express. Failure mode was silent under-detection
that read as "no updates" and left the tracker stale. Review finding F18
(2026-08-19).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
15 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 Final (per the Tracker status vocabulary in/outcome). For each, derive its archive folderdocuments/applications/<company>_<role>/by the Subfolder naming rule indocuments/README.mdand check whetheroutcome.mdexists there. Reuse this exact derived path for any write in Step 7a.draftedrows stay in this set, and are the reason it is worth searching./applywrites them but never submits; the user submits by hand and may not think to run/outcome. A reply arriving against a row still markeddraftedis exactly that case, and the row holds the company name the search needs. -
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:sent -in:drafts(status signals come from what employers send you, not what you sent them; the negative operators keep archived mail and label-filtered mail in scope - restricting to the Inbox instead would silently drop both, including exactly the mail matched by the job-search label from step 1, since the standard filter that applies such a label also archives it)
Example: newer_than:30d -in:sent -in:drafts ({"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" | drafted -> applied, otherwise (no change) |
On a drafted row this is the one email that proves the user submitted by hand, and it arrives within a day of them doing so - propose the move with date set to the email's date. On any other status it is 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) |
| 3 | ... | ... | Application ack | drafted -> applied, date -> 2026-07-02 | "Subject line" (2026-07-02) |
A row leaving `drafted` shows its date change in the status cell, as row 3 does: that row was never recorded as submitted, so Step 7a is about to replace the drafting date. Say that the date is taken from the email and ask whether the user knows the real submission date - approving the status move should not silently approve a date they can correct.
### 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. The rewrite touches onlystatus,notes(anddatewhen the drafted-rule below fires): preserve every other field of the row, parsed or not, so thedeadlinecolumn written by/applyStep 6b - or any column added in the future - is never blanked by a status sync.If the matched row was still
drafted, also setdateto the email's date. The employer replying proves the user submitted by hand without running/outcome, so the drafting date now in that column is wrong. The email's date is an upper bound on the real submission date, tight for an ack and loose for a rejection weeks later, which is why Step 6 shows it and lets the user supply the actual date instead. -
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, create the folder and a minimaloutcome.mdfollowing the exact format indocuments/README.md, same as/outcomewould. This is the normal case for a row that was stilldrafted:/applyStep 6b writes the tracker row and only/outcomeStep 3 ever creates the archive, so the folder legitimately does not exist yet. It is also the case for a row added by hand.
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.
Skip drafted rows here - nothing was sent, so no one is late replying.
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.