18 KiB
/sync-twenty - Push Ranked Jobs, Companies, and Applications to Twenty CRM
You are publishing a read-only view of the job search into the user's Twenty CRM: one Opportunity record per job, linked to one enriched Company record per employer. The repo files stay the system of record - job_scraper/seen_jobs.json owns scraped/ranked jobs and job_search_tracker.csv (when present) owns applications. Twenty is a disposable presentation layer on top of them; nothing ever syncs back.
This command requires the Twenty MCP server (.omp/mcp.json → twenty, OAuth). It reads local state, upserts company and opportunity records, and stops - it never ranks, applies, or edits repo files. Twenty is the pipeline backend; the sync contract itself is tool-agnostic (see "Adapting to Another Tool" at the end - only the sections marked (Twenty binding) are tool-specific).
Follow these steps in order.
Step 0: Parse Input
$ARGUMENTS may contain:
- Nothing → sync every ranked job (any score) plus every tracked application
--min-score <N>→ only sync ranked jobs whoserank_score≥ N (applications always sync regardless)--all→ alias for the default (every ranked job)--no-companies→ skip Steps 4-5's company enrichment/creation; opportunities sync unlinked (or keep existing links)
Step 1: Preflight the Connection (Twenty binding)
The command is silently optional: when the destination is not reachable, the outcome is one clear message and a clean exit - nothing else in the framework notices this command exists.
- Confirm the Twenty MCP tools are available in this session from the session's own tool list only (the meta-tools
get_tool_catalog,learn_tools,execute_tool, or the mountedmcp__twenty_*devices). Never shell out to probe the server. If unavailable, stop and tell the user:Twenty MCP isn't connected. Add the
twentyserver to.omp/mcp.json(HTTP transport, OAuth), start a new session, complete the OAuth login, then re-run/sync-twenty. - Verify the connection with one cheap call:
execute_tool→find_many_opportunitieswith{ "select": ["id"], "limit": 1 }. An auth error → tell the user to re-authenticate and stop. Never retry in a loop. - Twenty MCP is interactively authenticated, so "connected but not authenticable right now" (expired OAuth, headless/CI) gets the same graceful exit as "not configured": state the reason in one line and stop. Never initiate the OAuth flow from this command.
All record operations go through the execute_tool meta-tool, e.g. execute_tool(toolName="create_many_opportunities", arguments={...}). If a tool's schema is unfamiliar, call learn_tools for it first; never guess field shapes. Parse every response defensively: a failed call may return a plain error string instead of JSON - catch it, record the batch as failed, and continue.
Step 2: Build the Sync Set (local data only - no external calls yet)
Validate the cheap, local precondition before creating anything external. A run with nothing to sync must exit with zero side effects.
- Read
job_scraper/seen_jobs.json(structure:{ "seen": { "<url>": { ...job } } }) andjob_search_tracker.csv(may be missing). - Select
seen_jobs.jsonentries with statusranked. Apply the--min-scorethreshold from Step 0 when given; otherwise take all ranked entries.skippedentries never sync (they failed the location/language/salary gates and were never evaluated as opportunities).expiredentries only sync when they carry arank_score- a bare seen-then-expired job was never an opportunity. - Every tracker row joins the sync set (an applied-to job always syncs, ranked or not), matched to
seen_jobs.jsonentries case-insensitively on company + role. Tracker rows with noseen_jobs.jsonentry sync too. - Status precedence: the tracker wins. A job
rankedinseen_jobs.jsonbutinterviewin the tracker syncs asinterview. Jobs only inseen_jobs.jsonkeep their stored status. Deadline precedence: the tracker wins too - the tracker'sdeadlineoverridesseen_jobs.json; never reconcile the two dates withmin()/max(). - If the sync set is empty, say "Nothing to sync - run
/scrapeand/rankfirst" (or, when jobs exist but all score below--min-score, say so and suggest lowering it) and stop. - Derive the employer set: unique companies across the sync set, keyed by a normalized name - lowercase, punctuation collapsed to spaces, legal/noise suffixes stripped (
inc,llc,ltd,corp,co,labs,the, ...). Keep the first-seen display spelling for the record name. - State the counts before touching the destination: how many opportunities and how many companies will be created or checked, and the threshold in effect.
Step 3: Object Schemas and Sync State (Twenty binding)
The Twenty workspace already carries the required custom fields; this command assumes the schema exists and never mutates metadata. Confirm with get_object_metadata objectName="opportunity"|"company" includeFields=true only if a field is missing.
Opportunity (create_many_opportunities / upsert_many_opportunities):
| Field | Type | Source / notes |
|---|---|---|
name |
title (string) | "<Role> — <Company>" on create; cleaned to the role only by Step 7 once companyId is set - the suffix is a readable fallback for unlinked records, redundant after linking |
stage |
select enum | job status → enum (Step 6 mapping); required on create |
score |
number | rank_score (0-100); omit when unranked |
verdict |
select enum | rank_verdict → enum (Step 6 mapping); omit when unranked |
url |
links | { "primaryLinkUrl": <posting url>, "primaryLinkLabel": <role title> } - the dedup key |
location |
address | parse the location string: addressCity = text before the last comma, addressState = the remainder; whole string in addressCity when there is no comma |
closeDate |
date-time | deadline (tracker wins) as "YYYY-MM-DDT00:00:00.000Z"; omit when none |
companyId |
relation uuid | the employer's Company record (Step 5); omit under --no-companies |
amount |
currency | Salary - stated posting salary (max of the stated range) as { "amountMicros": <usd×1_000_000>, "currencyCode": "USD" }; filled by Step 7's enrichment ladder; omit when no grounded value exists |
Records passed to create_many_*/upsert_many_* also require position - use "last".
Company (create_many_companies) - stock Twenty fields:
| Field | Type | Source / notes |
|---|---|---|
name |
title (string) | display spelling from the sync set - the dedup key (normalized) |
domainName |
links | { "primaryLinkUrl": "https://<domain>", "primaryLinkLabel": <name> } - strip scheme/www./trailing slash from the source value first |
linkedinLink |
links | LinkedIn company page URL (http:// → https://) |
address |
address | HQ addressCity / addressState / addressCountry - only sub-fields actually known |
annualRevenue |
currency | organization revenue in USD micros; omit when 0/unknown |
Sync state: read/write job_scraper/twenty_sync.json (personal, gitignored - never commit):
{ "workspace_url": "https://crm.prad.nu", "last_sync": "YYYY-MM-DD" }
It exists only to record last_sync; posting URL (opportunities) and normalized name (companies) are the durable dedup anchors, so no id map is stored.
Step 4: Enrich the Employer Set (skip under --no-companies)
Enrichment fills domainName, linkedinLink, address, annualRevenue per employer. Sources, in priority order:
- Local first:
python3 tools/twenty_enrich.py. The script encodes the deterministic layer - it matches each normalized employer name againstdocuments/postings/jobs.db(companiestable; homepage domain joined throughpostings.company_domain, most frequent domain wins), sanitizes stringified-NaN/empty values (never emithttps://nan- Twenty rejects invalid link URLs and fails the whole batch on one bad record), and emitsjob_scraper/enrichment_plan.jsonwith anunresolvedsection listing exactly what local data cannot ground. - Web for the unresolved. Fan out
scoutsubagents in parallel batches (~14 companies each), each given the company name plus a sample job title, location, and posting URL for disambiguation (generic names like "Sigma" or "Picnic" collide badly without it). Each scout web-searches and returns{company, domain, linkedin, city, state, country}with null for anything search results don't clearly confirm - never guessed. - Perplexity for the stragglers. Companies still unresolved after the scout wave (name collisions, recruiters, very obscure startups) go to the Perplexity MCP (
perplexity_ask) in one batched question carrying the same disambiguation context; keep only facts it verifies with citations, and accept "unverified" as the answer.
An employer with no verified data still gets a record - name-only. Never invent a domain, LinkedIn slug, or HQ to make a record look complete; genuinely unverifiable employers (stealth startups, generic recruiter names) stay bare.
Step 5: Upsert Company Records (skip under --no-companies)
- Load existing CRM companies once: page
find_many_companies{ "select": ["id","name"], "limit": 100, "offset": … }untilhasNextPageis false. Mapnormalized name → id. - Reuse, don't recreate. Employers whose normalized name already exists in the CRM (including hand-added or seed records) keep the existing id for linking. Never overwrite an existing company's fields - it may carry the user's own edits.
- Create the rest in batches of ≤ 20 via
create_many_companies, each recordposition: "last"plus the Step 3 field map. Capture returned ids in input order into the name→id map. On a batch failure with a validation error (e.g. "The URL of the link is not valid"), bisect: retry that batch's records one-by-one to isolate and report the offender; fix by dropping the invalid field, not by guessing a replacement. - Never delete or archive company records.
Step 6: Upsert Opportunity Records
Twenty has no server-side upsert keyed on a custom field, so dedup client-side on the posting URL:
- Load existing rows once: page
find_many_opportunitieswith{ "select": ["id","name","stage","url"], "limit": 100, "offset": … }untilhasNextPageis false. Build a mapprimaryLinkUrl → id. - Split the sync set into records whose URL is absent from the map (create) and those present (update).
- Create in batches of ≤ 20 via
create_many_opportunities. Each record carriesposition: "last",stage,companyId(from Step 5's map, keyed by the job's normalized company name), and every mapped field from Step 3. Batch politely; on a rate-limit, back off and continue; report any batch that failed rather than retrying indefinitely. - Update matched rows in batches of ≤ 20 via
upsert_many_opportunities, each record carrying the explicitidplus the requiredposition/stageand the changed fields:stage,score,verdict,closeDate,companyId,amount. Passingidmakes the upsert match-and-update (verified:created: 0, updated: N); assert the response reports zero creates - an unexpected create means the id didn't match and a duplicate now exists. Do not rewriteurlon update - the URL is the identity.nameis rewritten only by Step 7's cleanup. - Never delete or archive records, even for
expiredjobs - setstagetoEXPIREDinstead. Rows the user added by hand (no matching posting URL) are invisible to this command.
Status → stage enum (normalise legacy space spellings first):
| job status | stage |
|---|---|
| ranked | RANKED |
| drafted | DRAFTED |
| applied | APPLIED |
| interview | INTERVIEW |
| offer | OFFER |
| hired | HIRED |
| rejected | REJECTED |
no_response / no response |
NORESPONSE |
offer_declined / offer declined |
OFFERDECLINED |
| withdrawn | WITHDRAWN |
| expired | EXPIRED |
rank_verdict → verdict enum: Strong Fit→STRONG_FIT, Good Fit→GOOD_FIT, Moderate Fit→MODERATE_FIT, Weak Fit→WEAK_FIT, Poor Fit→POOR_FIT. Omit verdict when the job is unranked or the string is unrecognised - never invent an enum value.
Step 7: Enrich Opportunities, then Clean Names
Run after Step 6 so every opportunity already has its companyId.
- Salary (
amount) ladder - first grounded source wins, encoded intools/twenty_enrich.py:seen_jobs.jsonsalary_max_stated(stated in the posting at rank time)jobs.dbpostingssalary_maxmatched on normalized company + title (stated on an ATS board)jobs.dbcompanies levels.fyi SWE median (company-level estimate - its provenance is named in the Fit Analysis note, never presented as a posting-stated figure)- Still missing → scout subagents re-fetch the posting page (salary often sits in JSON-LD) or find the same posting mirrored on another board; a posting that states no salary keeps an empty
amount- an unstated salary is a fact, not a gap to fill.
- Apply the script's plan:
--applywithTWENTY_API_KEYset, else upsert the plan's patches through the MCP session (explicitid, batches ≤ 20, assert zero creates). - Name cleanup (last). For every opportunity whose
companyIdis set and whosenamestill carries the" — <Company>"suffix, rewritenameto the role only (build_name_cleanup_planin the script). This runs after linking and enrichment because the suffix is the only company identification an unlinked record has.
Step 8: Sync Fit Analysis Notes
One note per synced opportunity, attached through noteTargets:
- Idempotency guard:
find_many_noteswithtitle.startsWith "Fit Analysis —"; skip opportunities that already have one. - Create via
create_many_notes(batches ≤ 20):title="Fit Analysis — <Role> @ <Company>",bodyV2.markdownbuilt from storedseen_jobs.jsondata only - score/verdict/quick-fit line, portal + dates line, employer tier / AI-tools context, salary gate (with the note naming a levels.fyi figure as an estimate),## Strengthsand## Gapsbullet lists, likely interview style, and the posting link. Never fetch or reconstruct posting text here. - Link each returned note id via
create_many_note_targetswith{ "noteId", "targetOpportunityId", "position": "last" }. - Notes are write-once: this step creates, never edits - the user may annotate them in Twenty. Email-derived context notes belong to
/gather-recent-context, not this command.
Step 9: Verify and Report
Verify before reporting: find_many_opportunities with { "companyId": { "is": "NOT_NULL" } } must count every synced opportunity (all of them, unless --no-companies); total opportunity count must equal pre-run count + creates (no upsert-spawned duplicates); the Fit Analysis note count must equal the opportunity count.
## Twenty CRM Sync - YYYY-MM-DD
Workspace: <workspace_url>
Opportunities: <N> synced (threshold: score ≥ <T | none>): <C> created, <U> updated, <F> failed.
Companies: <M> employers: <C> created, <R> reused, <E> enriched (domain/linkedin), <B> name-only.
Linked: <L>/<N> opportunities → companies · Salary: <S>/<N> · Notes: <K>/<N>.
| | Opportunity | Company | Stage | Score |
|---|-----------|---------|-------|-------|
| ✚ | Senior Backend Engineer - Performance | Teleport | RANKED | 86 |
| ↻ | Software Engineer, Bridge | Stripe | INTERVIEW | 86 |
List failures with their error and the suggestion to re-run - the upsert is idempotent (posting URL for opportunities, normalized name for companies), so a re-run only touches what failed. Update last_sync in job_scraper/twenty_sync.json.
Remind the user once (first run only): the repo files remain the source of truth - edits made in Twenty never flow back, and /outcome is still how application results get recorded.
Important Rules
- One-way, always. Twenty content never flows back into
seen_jobs.json, the tracker, or any repo file. The gitignoredtwenty_sync.jsonis this command's only local write. - Idempotent upserts. Opportunities dedup on
url.primaryLinkUrl; companies dedup on normalized name. Re-running creates nothing twice; matching is never fuzzy title comparison. - Metadata is read-only. Assume the custom fields already exist; never create, retype, or delete object/field metadata from this command.
- Batch cap is 20. Every
create_many_*/upsert_many_*call rejects more; always chunk. One invalid record fails its whole batch - bisect on validation errors. - Never fabricate. Omit a field rather than guess it: no invented enum values, deadlines, domains, or LinkedIn slugs. Enrichment is jobs.db or verified web search - unverifiable employers stay name-only.
- Existing companies belong to the user. Reuse their id for linking; never overwrite or delete their fields.
- Job data only. The candidate profile, behavioral notes, and evaluation framework never sync - this is a pipeline view, not a profile export.
- Documents never leave the machine. CVs and cover letters are not uploaded or embedded; the local repo and
documents/applications/archive are their only home.
Adapting to Another Tool (forks)
The sync contract is tool-agnostic; only the sections marked (Twenty binding) are tool-specific. A fork targeting a different CRM keeps Steps 0, 2, 4, 7-9, and every Important Rule unchanged - build the same sync set, enrich the same employer set, upsert on the same posting-URL/company-name keys, keep the destination read-only-to-source and documents local - and swaps only:
- Step 1 (connection preflight) for the target tool's MCP server or access check, keeping the silently-optional bar: not configured or not authenticable both end in one message and a clean exit.
- Steps 3, 5-6 (schemas, upsert mechanics) for the target tool's records API, reusing the same field tables and a renamed sync-state file.