6.2 KiB
/gather-recent-context - Sync Job-Email Context into Twenty CRM Notes
You are scanning the user's Fastmail inbox for conversations about active job loops (interview invites, assessments, scheduling, offers, recruiter follow-ups) and syncing each conversation's context onto its matching Opportunity in Twenty CRM as a Note. The mailbox is read-only to this command; Twenty receives classified summaries, never raw email bodies or attachments.
Two halves, cleanly split:
- You (this command) own the judgment: finding relevant threads via the Fastmail MCP tools and classifying each one into a structured signal.
tools/gather_recent_context.pyowns everything deterministic: matching threads to active opportunities, building the note markdown, deduplicating by thread id (job_scraper/context_sync.json), and applying via Twenty REST (TWENTY_API_KEY) - or emitting a plan you apply through the Twenty MCP session.
Follow these steps in order.
Step 0: Parse Input
$ARGUMENTS may contain:
- Nothing → default lookback: 14 days
since <YYYY-MM-DD>→ override the lookback start- A company name → scope the scan to that one company's threads
Step 1: Preflight (both MCP servers)
Silently optional, same bar as /sync-twenty: a missing prerequisite ends in one clear message and a clean exit.
- Fastmail must be reachable one way or the other: the Fastmail MCP tools (
search_email,read_thread,search_events) in an interactive session, orFASTMAIL_API_TOKENin the environment for the headless JMAP client (python3 tools/fastmail_client.py search|thread|events|draft|reply- same search surface, plus calendar and reply-drafting; drafts only, it never sends). Neither → say so and stop. - Twenty must be reachable one way or the other:
TWENTY_API_KEYin the environment (script applies via REST) or the Twenty MCP session (you apply the plan). Neither → one line and stop. - Never initiate an OAuth flow from this command.
Step 2: Find Candidate Threads (Fastmail MCP)
- Build the search set from the CRM's active loops: fetch opportunities with stage in
RANKED / DRAFTED / APPLIED / INTERVIEW / OFFER(viaexecute_tool→find_many_opportunities, selectingid,name,stage,companyId) and the company names viafind_many_companies(id,name). Opportunity names are role-only; the employer comes from thecompanyIdrelation. search_emailwith the lookback from Step 0 - query for recruiting/job signals (subject or sender mentioning the tracked companies, plus generic signals like "interview", "assessment", "application", "offer", "recruiter"). Prefer several narrow searches over one broad one; skip newsletters and job-board digests (LinkedIn/Indeed notification blasts are noise, not conversations).read_threadon each candidate to see the whole conversation before classifying.
Step 3: Classify into the Contract
For each relevant thread, produce one entry - this is the exact input contract of the script:
{"threads": [{
"thread_id": "<Fastmail thread id>",
"company": "<employer name as the email identifies it>",
"role": "<role title if the thread names one, else null>",
"date": "YYYY-MM-DD",
"from": "<counterparty address>",
"subject": "<subject line>",
"summary": "<1-3 sentences: what happened and what's next>",
"signal": "interview | assessment | offer | rejection | scheduling | other"
}]}
Rules: the summary is your words, not a quote; no email bodies, links, or attachments are copied; a thread you cannot confidently tie to a job conversation is dropped, not guessed. Write the JSON to a temp file (e.g. /tmp/gather-emails.json).
Step 4: Run the Script
python3 tools/gather_recent_context.py --emails /tmp/gather-emails.json # plan only
python3 tools/gather_recent_context.py --emails /tmp/gather-emails.json --apply # with TWENTY_API_KEY
The script matches threads to active opportunities (normalized company name, role-token tiebreak), groups them into one note per opportunity titled Email Context — <Company> (YYYY-MM-DD), skips every thread id already in job_scraper/context_sync.json, and writes job_scraper/context_plan.json.
- With
--apply(REST key available): notes and note-targets are created and the dedup state is committed. Done - go to Step 6. - Without a key: the plan file is your work order - go to Step 5.
Step 5: Apply the Plan via Twenty MCP (keyless path)
For each entry in the plan's notes array:
create_many_noteswith{position: "last", title, bodyV2: {markdown}}(batches of ≤ 20).create_many_note_targetslinking each returned note id to itsopportunity_idviatargetOpportunityId.- After all notes land, commit the dedup state:
Never commit state for notes that failed to create - the uncommitted thread ids simply reappear next run.
python3 tools/gather_recent_context.py --commit-plan job_scraper/context_plan.json
Step 6: Report
## Recent Context Sync - YYYY-MM-DD
Scanned <N> threads (lookback: <D> days) · <M> classified as job conversations
Notes created: <K> (one per opportunity) · Unmatched: <U> · Already processed: <P>
| Opportunity | Signal(s) | Threads |
|-------------|-----------|---------|
| Backend Engineer — Acme | interview, scheduling | 2 |
List unmatched threads (company + subject) so the user can add the opportunity or correct the company name; they are not state-committed and will re-surface next run.
Important Rules
- Mailbox read-only. Never label, archive, delete, or send from this command.
- Summaries only. Raw email bodies, quoted text, links, and attachments never reach Twenty.
- Active loops only. Threads about companies with no active-stage opportunity go to
unmatched, never to a closed loop's record. - Idempotent by thread id.
job_scraper/context_sync.json(personal state, gitignored) guarantees a re-run never duplicates a note. - Notes are append-only context. This command creates new notes; it never edits or deletes existing ones - including the
Fit Analysisnotes owned by/sync-twenty. - Never guess a match. An ambiguous thread is reported, not synced.