# /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 ` → only sync ranked jobs whose `rank_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. 1. 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 mounted `mcp__twenty_*` devices). Never shell out to probe the server. If unavailable, stop and tell the user: > Twenty MCP isn't connected. Add the `twenty` server to `.omp/mcp.json` (HTTP transport, OAuth), start a **new session**, complete the OAuth login, then re-run `/sync-twenty`. 2. Verify the connection with one cheap call: `execute_tool` → `find_many_opportunities` with `{ "select": ["id"], "limit": 1 }`. An auth error → tell the user to re-authenticate and stop. Never retry in a loop. 3. 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**. 1. Read `job_scraper/seen_jobs.json` (structure: `{ "seen": { "": { ...job } } }`) and `job_search_tracker.csv` (may be missing). 2. Select `seen_jobs.json` entries with status `ranked`. Apply the `--min-score` threshold from Step 0 when given; otherwise take all ranked entries. **`skipped` entries never sync** (they failed the location/language/salary gates and were never evaluated as opportunities). **`expired` entries only sync when they carry a `rank_score`** - a bare seen-then-expired job was never an opportunity. 3. Every tracker row joins the sync set (an applied-to job always syncs, ranked or not), matched to `seen_jobs.json` entries case-insensitively on company + role. Tracker rows with no `seen_jobs.json` entry sync too. 4. **Status precedence: the tracker wins.** A job `ranked` in `seen_jobs.json` but `interview` in the tracker syncs as `interview`. Jobs only in `seen_jobs.json` keep their stored status. **Deadline precedence: the tracker wins too** - the tracker's `deadline` overrides `seen_jobs.json`; never reconcile the two dates with `min()`/`max()`. 5. **If the sync set is empty**, say "Nothing to sync - run `/scrape` and `/rank` first" (or, when jobs exist but all score below `--min-score`, say so and suggest lowering it) and **stop**. 6. 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. 7. 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) | `" — "` 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": , "primaryLinkLabel": }` - 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": , "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://", "primaryLinkLabel": }` - 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): ```json { "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: 1. **Local first: `python3 tools/twenty_enrich.py`.** The script encodes the deterministic layer - it matches each normalized employer name against `documents/postings/jobs.db` (`companies` table; homepage domain joined through `postings.company_domain`, most frequent domain wins), sanitizes stringified-NaN/empty values (never emit `https://nan` - Twenty rejects invalid link URLs and **fails the whole batch** on one bad record), and emits `job_scraper/enrichment_plan.json` with an `unresolved` section listing exactly what local data cannot ground. 2. **Web for the unresolved.** Fan out `scout` subagents 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. 3. **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`) 1. Load existing CRM companies once: page `find_many_companies` `{ "select": ["id","name"], "limit": 100, "offset": … }` until `hasNextPage` is false. Map `normalized name → id`. 2. **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. 3. **Create** the rest in batches of **≤ 20** via `create_many_companies`, each record `position: "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. 4. 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**: 1. Load existing rows once: page `find_many_opportunities` with `{ "select": ["id","name","stage","url"], "limit": 100, "offset": … }` until `hasNextPage` is false. Build a map `primaryLinkUrl → id`. 2. **Split the sync set** into records whose URL is absent from the map (**create**) and those present (**update**). 3. **Create** in batches of **≤ 20** via `create_many_opportunities`. Each record carries `position: "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. 4. **Update** matched rows in batches of ≤ 20 via `upsert_many_opportunities`, each record carrying the **explicit `id`** plus the required `position`/`stage` and the changed fields: `stage`, `score`, `verdict`, `closeDate`, `companyId`, `amount`. Passing `id` makes 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 rewrite `url` on update - the URL is the identity. `name` is rewritten only by Step 7's cleanup. 5. Never delete or archive records, even for `expired` jobs - set `stage` to `EXPIRED` instead. 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`. 1. **Salary (`amount`) ladder** - first grounded source wins, encoded in `tools/twenty_enrich.py`: 1. `seen_jobs.json` `salary_max_stated` (stated in the posting at rank time) 2. `jobs.db` postings `salary_max` matched on normalized company + title (stated on an ATS board) 3. `jobs.db` companies levels.fyi SWE median (company-level **estimate** - its provenance is named in the Fit Analysis note, never presented as a posting-stated figure) 4. 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**. 2. **Apply** the script's plan: `--apply` with `TWENTY_API_KEY` set, else upsert the plan's patches through the MCP session (explicit `id`, batches ≤ 20, assert zero creates). 3. **Name cleanup (last).** For every opportunity whose `companyId` is set and whose `name` still carries the `" — "` suffix, rewrite `name` to the role only (`build_name_cleanup_plan` in 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`: 1. **Idempotency guard:** `find_many_notes` with `title.startsWith "Fit Analysis —"`; skip opportunities that already have one. 2. **Create** via `create_many_notes` (batches ≤ 20): `title` = `"Fit Analysis — @ "`, `bodyV2.markdown` built from stored `seen_jobs.json` data 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), `## Strengths` and `## Gaps` bullet lists, likely interview style, and the posting link. **Never fetch or reconstruct posting text here.** 3. **Link** each returned note id via `create_many_note_targets` with `{ "noteId", "targetOpportunityId", "position": "last" }`. 4. 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: Opportunities: synced (threshold: score ≥ ): created, updated, failed. Companies: employers: created, reused, enriched (domain/linkedin), name-only. Linked: / opportunities → companies · Salary: / · Notes: /. | | 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 1. **One-way, always.** Twenty content never flows back into `seen_jobs.json`, the tracker, or any repo file. The gitignored `twenty_sync.json` is this command's **only** local write. 2. **Idempotent upserts.** Opportunities dedup on `url.primaryLinkUrl`; companies dedup on normalized name. Re-running creates nothing twice; matching is never fuzzy title comparison. 3. **Metadata is read-only.** Assume the custom fields already exist; never create, retype, or delete object/field metadata from this command. 4. **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. 5. **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. 6. **Existing companies belong to the user.** Reuse their id for linking; never overwrite or delete their fields. 7. **Job data only.** The candidate profile, behavioral notes, and evaluation framework never sync - this is a pipeline view, not a profile export. 8. **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.