fn ping()
Probe the Twenty instance: an authed GET /rest/people?limit=1. A 2xx proves it is reachable AND the token authenticates. Snapshots a `capability` resource.
fn introspectSchema()
Read live object/field metadata (GET /rest/metadata/objects): list objects, discover the Opportunity stage enum, and report whether the required leadId/isEmergency custom fields exist on Person/Opportunity/Note. Snapshots a `schema` resource. Run this before trusting leadId filters.
fn ensureLeadFields()
Idempotently provision the custom fields the lead sink depends on: leadId (TEXT) on Person/Opportunity/Note and isEmergency (BOOLEAN) on Person/Opportunity, via POST /rest/metadata/fields. Already-present fields are skipped. Confirm-gated (mutates workspace metadata). Snapshots a `fieldsEnsured` resource.
fn ensureNoteFields()
Idempotently provision the note-reconciliation custom fields (TWENTY-NOTE-RECONCILE): noteHash (TEXT) on Note, via POST /rest/metadata/fields. A thin wrapper over the shared ensureFieldOnce core, paralleling ensureLeadFields. Already-present fields are skipped (a wrong-type field is reported, never mutated). Confirm-gated (mutates workspace metadata). Provisioning noteHash ENABLES population: createNote/updateNote then populate noteHash and REQUIRE the noteHashHmacKey global (SEC-5 fail-closed o
fn ensureStageOption()
Idempotently ensure a SELECT option exists on an allowlisted picklist field (default opportunity.stage), so an Opportunity can be set to a stage the workspace didn't ship with (e.g. CLOSED). Reads the field's FULL option set and appends the new option, preserving every OTHER existing option (id/label/color/position) verbatim — never a drop or reorder. If the value already exists with a differing label/color it is, by default (reconcile), updated IN PLACE (id/position preserved) since swamp owns
fn removeSelectOption()
Retire a SELECT option from an allowlisted opportunity picklist (opportunity.stage/sourceChannel/lineOfBusiness/offering) — the reverse of ensureStageOption. Reads the field's FULL option set and PATCHes it back with the target value DROPPED, preserving every surviving option (id/label/color/position) verbatim. FAIL-CLOSED anti-orphan guard: refuses when the value is the field default, when any opportunity still references it (default path), or when the reference count is untrusted. Idempotent —
fn findPersonByLeadId(leadId: string)
Look up a Person by the immutable leadId marker (GET /rest/people?filter=leadId[eq]:<id>). Snapshots a `record` reference if found; writes nothing if not.
| Argument | Type | Required | Description |
|---|
| leadId | string | yes | Opaque upstream lead id |
fn findOpportunityByLeadId(leadId: string)
Look up an Opportunity by the immutable leadId marker (GET /rest/opportunities?filter=leadId[eq]:<id>) — the primary idempotency check. Snapshots a `record` reference if found.
| Argument | Type | Required | Description |
|---|
| leadId | string | yes | Opaque upstream lead id |
fn findPerson()
Look up a Person by primary email OR leadId (exactly one). Read-only existence check for reconcile/dedup: records a `personRef` on both hit (found:true + id) and miss (found:false), so callers can tell 'looked, not there' from 'never looked'. Throws on an ambiguous (>1) email match. No writes.
fn getPersonById(id: string)
Fetch a Person by UUID (GET /rest/people/{id}); records a `personRef` with found:false on a 404. Read-only — lets the reconcile report walk from an opportunity's pointOfContactId back to a contact.
| Argument | Type | Required | Description |
|---|
| id | string | yes | Person UUID |
fn findCompany()
Look up a Company by domain OR exact name (exactly one). Records a `companyRef` on hit and miss. Throws on an ambiguous (>1) match; the name path returns found:false gracefully if the name field is not filterable on the instance. No writes.
fn getCompanyById(id: string)
Fetch a Company by UUID (GET /rest/companies/{id}); records a `companyRef` with found:false on a 404. Read-only — lets the reconcile report resolve an opportunity's companyId.
| Argument | Type | Required | Description |
|---|
| id | string | yes | Company UUID |
fn listViews()
Read-only Phase-0 view probe (TWENTY-VIEW-MGMT): resolve which view endpoint is live (core REST /rest/views vs the metadata REST API /rest/metadata/views — views are metadata objects, not core-REST ones, and Core GraphQL has no views query), classify each candidate (ok/absent/forbidden/error), authoritatively fetch the Opportunity views + their viewFilters, resolve stage/lineOfBusiness field ids + live options, and report the locked default (key==='INDEX') and uncovered stage options. GATES the
fn ensureOpportunityViews(confirm?: boolean, dryRun?: boolean, names?: array)
Gated write (TWENTY-VIEW-MGMT): idempotently CREATE the target Opportunity saved views (Sales Pipeline = stage IS [NEW,SCREENING,MEETING,PROPOSAL]; Consulting/Hosting/Local IT = lineOfBusiness IS [<LOB>]), each with a single `IS`-any-of viewFilter via the metadata API. NON-MUTATING by construction: creates a view only when its name is absent, no-ops when it already carries the desired filter, and REFUSES (never mutates) any pre-existing / locked / ambiguous view (F2/F3). Binds filters to a live
| Argument | Type | Required | Description |
|---|
| confirm? | boolean | no | |
| dryRun? | boolean | no | |
| names? | array | no | |
fn upsertOpportunityViews(views: array, confirm?: boolean, dryRun?: boolean)
TWENTY-VIEW-UPSERT (fan-out, repo rule 6): reconcile a desired LIST of Opportunity views to a YAML source-of-truth in ONE execution. Per view: create (name absent) / adopt+rename (adoptFrom an existing swamp-owned view) / reconcile its viewFilters (add missing, delete extra, patch operand/value drift, patch group AND/OR operator, collapse grouped->ungrouped) / present (already matching, quiet no-op) / refuse (fail-closed). Additive — does NOT change the create-only ensureOpportunityViews. Fail-c
| Argument | Type | Required | Description |
|---|
| views | array | yes | Desired view list (all scoped to Opportunity). Each: { name, filters:[{field,operand,values[],subFieldName?}], filterGroup?('and'|'or', required when >1 filter), adoptFrom?(existing view name or viewId) }. |
| confirm? | boolean | no | Execute the writes. Default false = plan only. |
| dryRun? | boolean | no | Plan without writing (default true). A write requires confirm=true AND dryRun=false. |
fn upsertOpportunityView(name: string, filters: array, filterGroup?: enum, adoptFrom?: string, confirm?: boolean, dryRun?: boolean)
TWENTY-VIEW-UPSERT (single-view convenience over upsertOpportunityViews): reconcile ONE Opportunity view to a desired filter state — create / adopt+rename / reconcile / present / refuse, with the same fail-closed guardrails, stringified-value writes, and read-back verification. Prefer upsertOpportunityViews for multiple views (one lock acquisition). confirm-gated, dryRun default. Snapshots viewEnsured2.
| Argument | Type | Required | Description |
|---|
| name | string | yes | Desired final view name (the stable identity within the object). |
| filters | array | yes | Desired filter clauses: [{field, operand(IS|IS_NOT), values[], subFieldName?}]. |
| filterGroup? | enum | no | Boolean combinator for a multi-clause view (required when filters.length>1). |
| adoptFrom? | string | no | Existing view NAME or viewId to rename->name + reconcile (the adoption opt-in). |
| confirm? | boolean | no | |
| dryRun? | boolean | no | |
fn aggregateOpportunities()
Read-only CRM pipeline analytics: aggregate all Opportunities into PII-free metrics — counts + per-currency sums grouped by stage / lineOfBusiness / sourceChannel (observed values, incl. a legacy CLOSED bucket), open-pipeline value (NEW/SCREENING/MEETING/PROPOSAL), win/loss + win rate (won = CUSTOMER+CLOSED_WON, lost = CLOSED_LOST, n/a on 0/0), and active customers. isEmergency opps are EXCLUDED by default (never disclose emergency volume). GraphQL-primary: pages the Core-GraphQL opportunities c
fn getOpportunity()
Fetch one Opportunity by leadId OR id (exactly one). Records an `opportunityRef` snapshot carrying the reconcile-critical fields (id, leadId, name, stage, amount in whole units, currencyCode, closeDate, companyId, pointOfContactId, channelPartnerId) plus the segmentation SELECTs (lineOfBusiness, sourceChannel, offering) and isEmergency when set — so a written custom-field value is read-back verifiable; found:false + no fields on a miss. No writes.
fn listOpportunities(companyId?: string, stage?: string)
Fan-out read (repo rule 6): list Opportunities filtered by companyId and/or stage (both optional; neither => all, capped). Composes filters with AND, pages through Twenty's cursor pagination up to `limit` (hard-capped at 500), dedups by id, and records an `opportunityList` snapshot of compact views (id/leadId/name/stage/amount/currency/closeDate/companyId/channelPartnerId + segmentation lineOfBusiness/sourceChannel/offering + isEmergency when set) + a `truncated` flag. No writes, no per-id loop.
| Argument | Type | Required | Description |
|---|
| companyId? | string | no | Filter: company UUID |
| stage? | string | no | Filter: opportunity stage (e.g. PROPOSAL) |
fn listPeople(companyId?: string, leadId?: string, includeEmergency: boolean, startingAfter?: string)
Fan-out read (repo rule 6): list People, optionally filtered by companyId and/or leadId (both optional; neither => all, capped-and-continued). Emergency-restricted rows are EXCLUDED by default via a NULL-safe clause (AR-5+SR-3). Composes filters with AND, sends order_by=createdAt,id, pages Twenty's cursor pagination up to a per-call cap (500), dedups by id, and records a `peopleList` page snapshot (join keys only — NO name/email/phone/jobTitle) with continuation + completeness fields. No writes,
| Argument | Type | Required | Description |
|---|
| companyId? | string | no | Filter: company UUID (Twenty's flat relation FK) |
| leadId? | string | no | Filter: immutable lead marker (TEXT custom field) |
| includeEmergency | boolean | yes | When false (default) excludes emergency-restricted rows NULL-safely (false OR unset); true opts them in |
| startingAfter? | string | no | Continuation cursor: a prior call's nextCursor, to resume paging past the per-call cap |
fn listCompanies(domain?: string, name?: string, startingAfter?: string)
Fan-out read (repo rule 6): list Companies, optionally filtered by domain (domainName.primaryLinkUrl) and/or name (both optional; neither => all, capped-and-continued). Composes filters with AND, sends order_by=createdAt,id, pages up to a per-call cap (500), dedups by id, and records a `companyList` page snapshot (id/name/domain) with continuation + completeness fields. No writes, no per-id loop.
| Argument | Type | Required | Description |
|---|
| domain? | string | no | Filter: corporate domain (normalized to a bare host, e.g. acme.com) |
| name? | string | no | Filter: exact company name (filter-safe; reserved chars bounded-reject) |
| startingAfter? | string | no | Continuation cursor: a prior call's nextCursor |
fn listNotes(leadId?: string, startingAfter?: string)
Fan-out read (repo rule 6): list Notes, optionally filtered by leadId (optional; absent => ALL notes, capped-and-continued). Sends order_by=createdAt,id, pages up to a per-call cap (500), dedups by id, and records a `noteList` page snapshot with continuation + completeness fields. Note body (bodyV2.markdown) is NEVER included; title is emitted ONLY on the machine 'Inbound lead ' pattern (SR-1). No emergency filtering: this extension does not provision isEmergency on Note (only Person/Opportunity
| Argument | Type | Required | Description |
|---|
| leadId? | string | no | Filter: immutable lead marker (TEXT custom field on Note) |
| startingAfter? | string | no | Continuation cursor: a prior call's nextCursor |
fn listNotesByOpportunity()
Fan-out read (repo rule 6): discover the Notes linked to an Opportunity via noteTargets (targetOpportunityId), returning body-FREE compact views (mapNoteView — SR-1 preserved: id/leadId/machine-'Inbound lead' title/dates, NEVER a body). Resolves the opp's noteTargets page, then reads each linked Note. Records a `noteList` snapshot (instance note-list-opp-<id>). Primary use: find noteIds for getNoteBody when a note is NOT leadId-tagged (so listNotes can't find it). No writes.
fn getNoteBody(noteId: string)
SR-1 EXCEPTION — reads ONE Note's body (bodyV2.markdown) across the privacy boundary that listNotes/mapNoteView deliberately withhold. confirm:true REQUIRED as an explicit acknowledgement that this crosses the note-body privacy boundary (NOT a mutation gate). noteId must be a UUID. Result is delivered via a SHORT-TTL (3d) noteBodyRead snapshot — read data.latest('noteBodyRead','note-body-<id>').attributes.body; the body is PII-at-rest bounded by that TTL. 404 => found:false. found:true with no m
| Argument | Type | Required | Description |
|---|
| noteId | string | yes | Note UUID to read the body of |
fn deleteNote()
Delete the Note carrying a given leadId (resolved via the leadId filter), or an explicit noteId. Confirm-gated; dryRun resolves the target and plans without deleting. Idempotent — a missing Note is reported (found:false), not an error. Primary use: regenerate a lead Note after a format change (delete, then re-run push_leads, which recreates it). Records a noteDelete resource.
fn createNote(title: string, opportunityId?: string, personId?: string, companyId?: string, dryRun: boolean, confirm: boolean)
Create a Note (title + optional markdown body) and link it to opportunity/person/company targets, idempotently. With leadId: dedups on the Note carrying it (reuse + reconcile targets, never duplicate) — the push_leads contract. Without leadId: always creates (caller owns identity; amend via updateNote/appendNote). Body written VERBATIM (untrusted-input callers MUST escapeMarkdown first); title sanitized (≤200). confirm-gated, dryRun-by-default. Records a noteWrite snapshot (no body text). NOTE:
| Argument | Type | Required | Description |
|---|
| title | string | yes | Note title (sanitized, ≤200 chars) |
| opportunityId? | string | no | |
| personId? | string | no | |
| companyId? | string | no | |
| dryRun | boolean | yes | |
| confirm | boolean | yes | |
fn updateNote(noteId: string, title?: string, dryRun: boolean, confirm: boolean)
Update a Note's title and/or body (full REPLACE, not append) via PATCH /rest/notes/{id}. Title sanitized; body verbatim. Refuses a body write on a note carrying a non-empty blocknote unless markdown re-derivation is proven (design §6/A5/S6). 404 → not-found. confirm-gated, dryRun-by-default. Records a noteWrite snapshot (no body text).
| Argument | Type | Required | Description |
|---|
| noteId | string | yes | |
| title? | string | no | New title (sanitized, ≤200) |
| dryRun | boolean | yes | |
| confirm | boolean | yes | |
fn appendNote(noteId: string, text: string, dryRun: boolean, confirm: boolean)
Append markdown to a Note's body (read-modify-write). confirm:true REQUIRED — it acknowledges that this READS the existing note body across the SR-1 privacy boundary; without confirm NO read happens (a dryRun-without-confirm returns a shallow 'would append' plan; a non-dry call refuses). The pre-existing body is NEVER snapshotted, logged, or thrown. Refuses on a note carrying a non-empty blocknote unless markdown re-derivation is proven (design §6/A5/S6). 404 → not-found. dryRun-by-default (with
| Argument | Type | Required | Description |
|---|
| noteId | string | yes | |
| text | string | yes | Markdown to append (verbatim) |
| dryRun | boolean | yes | |
| confirm | boolean | yes | |
fn linkNote(noteId: string, target: object, dryRun: boolean, confirm: boolean)
Link a Note to ONE record (opportunity/person/company) via a noteTarget, idempotently. Already linked → already-linked (exact-id match, A4). Verifies the created row carries the intended id (S7). confirm-gated, dryRun-by-default. Records a noteWrite snapshot.
| Argument | Type | Required | Description |
|---|
| noteId | string | yes | |
| target | object | yes | |
| dryRun | boolean | yes | |
| confirm | boolean | yes | |
fn unlinkNote(noteId: string, target: object, dryRun: boolean, confirm: boolean)
Remove ONE noteTarget link (opportunity/person/company) from a Note by exact-id match (A4) — never deletes the Note, never a non-matching target. Absent → not-linked. confirm-gated, dryRun-by-default. Records a noteWrite snapshot.
| Argument | Type | Required | Description |
|---|
| noteId | string | yes | |
| target | object | yes | |
| dryRun | boolean | yes | |
| confirm | boolean | yes | |
fn push_leads()
THE fan-out lead sink (repo rule 6). Ingest a batch of contact-form leads into Twenty in one execution: select status=='new', FIFO by received_at, capped at maxBatch. Per lead, isolated in try/catch: validate + sanitize every field FIRST (bad lead => failed, no writes); reuse but NEVER structurally mutate an existing Person; create/link a Company only for business leads on a real corporate domain; create exactly one Opportunity keyed on leadId (skip if it exists); ALWAYS ensure the per-lead mess
fn upsertOpportunity(name: string)
Generalized, idempotent Opportunity upsert keyed on leadId — the create/update path with the full field set (name, amount, stage, closeDate, company, point of contact, channelPartner, and the lineOfBusiness/sourceChannel/offering segmentation SELECTs) that push_leads' bare createOpportunity omits. Finds any existing Opportunity by leadId: hit => PATCH the provided fields; miss => create. Optionally finds-or-creates and links a Company (by domain, else by exact name) and a point-of-contact Person
| Argument | Type | Required | Description |
|---|
| name | string | yes | Opportunity name |
fn upsertRecord()
Generic, idempotent create-or-update for a single record of an eligible CUSTOM object, keyed on a caller-chosen natural-key field. Object-agnostic sibling of upsertOpportunity, fail-closed throughout: objectNameSingular MUST be in a strict in-code allowlist (v1: subscription, channelPartner) — any other object errors before any I/O; matchField must be a camelCase, non-reserved, scalar field that exists on the object; every `fields` key must exist and be a scalar TYPE (TEXT/NUMBER/BOOLEAN/DATE_TI
fn upsertPerson(firstName?: string, lastName?: string, phone?: string, jobTitle?: string, city?: string)
Idempotent, confirm-gated curated-contact writer keyed on primaryEmail. Find-or-create a Person and set name/phone/jobTitle/city and an optional company link: hit => PATCH only the provided fields (a re-run never clobbers an unset field, and a partial name never nulls the other subfield); miss => create. Unlike push_leads/createPerson this NEVER stamps a leadId (curated contacts stay out of the lead namespace). Company is deduped by domain then exact name and created only when a domain is suppli
| Argument | Type | Required | Description |
|---|
| firstName? | string | no | First name |
| lastName? | string | no | Last name |
| phone? | string | no | Phone (normalized toward E.164) |
| jobTitle? | string | no | Job title |
| city? | string | no | City |
fn ensureField(label?: string)
Idempotently provision ONE custom field on an object via POST /rest/metadata/fields — the generalized foundation ensureLeadFields is now a thin wrapper over. Supports TEXT / BOOLEAN / NUMBER / DATE_TIME / SELECT. Non-destructive to structure: an absent field is created; a present scalar field is a no-op (a differing type is reported, never mutated); a present SELECT gets NEW options APPENDED and, by default (reconcile), existing options whose label/color drifted RECONCILED in place (id/position
| Argument | Type | Required | Description |
|---|
| label? | string | no | Display label |
fn ensureOpportunitySegmentation()
Fan-out (repo rule 6): idempotently provision the three Opportunity segmentation SELECT fields — Line of Business (Consulting / Hosting / Games), Source Channel (Direct / Referral / Consulting hand-off), and Offering (Managed / Substrate / Project / Retainer / Local IT / Peering) — through the shared append-only ensureField path in ONE execution (single GET, one lock). Analytics only, not a pipeline gate. Re-run is a clean no-op when the live options already match the spec; a field that exists w
fn ensureObject()
Idempotently provision ONE custom OBJECT (object metadata type) via POST /rest/metadata/objects — the schema-provisioning foundation relation/field work builds on. Non-destructive: an object whose nameSingular/namePlural already exists is a no-op (reported as `present`, never mutated); a missing object is created. nameSingular/namePlural are camelCase identifiers and must differ; labels default to the title-cased names. dryRun:true (the DEFAULT) validates + plans (planned-create) and writes noth
fn ensureRelation()
Idempotently provision ONE custom RELATION between two existing objects. In Twenty a relation IS a field: this POSTs to /rest/metadata/fields with type RELATION plus a relationCreationPayload block, and the ONE call auto-creates BOTH sides (the reverse field on the target is server-minted from targetFieldLabel — never posted here). relationType is MANY_TO_ONE or ONE_TO_MANY only (no first-class MANY_TO_MANY). Non-destructive: a source field whose name already exists as a RELATION is a no-op (`pr
Resources
capability(infinite)— Reachability + auth probe snapshot
schema(infinite)— Introspection snapshot: objects, opportunity stage enum, required custom-field presence
fieldsEnsured(infinite)— Record of an ensureLeadFields run (created/present/failed)
record(infinite)— Reference to a single Person/Company/Opportunity/Note touched by a base method
pushRun(infinite)— Audit of one push_leads run: counts + per-lead results (no raw PII beyond leadId)
opportunityUpsert(infinite)— Result of an upsertOpportunity run: the action taken and the resolved opportunity/company/contact ids
recordUpserted(infinite)— Result of an upsertRecord run: the action taken, the resolved custom object/plural, the natural-key field, the hashed match value, and the create/patch payload (no raw match value, no Twenty response body)
personRef(infinite)— Existence-check snapshot from findPerson/getPersonById (found + join keys, no bulk PII)
companyRef(infinite)— Existence-check snapshot from findCompany/getCompanyById (found + id/domain/name)
opportunityRef(infinite)— Snapshot from getOpportunity: the reconcile-critical opportunity fields (id, leadId, name, stage, amount, ...)
opportunityList(infinite)— Snapshot from listOpportunities: a filtered, paginated set of opportunity views + truncation flag
viewList(3d)— Snapshot from listViews: the Phase-0 view probe — endpoint verdicts, opportunity views, and a raw sample for lock-flag/viewFilter shape discovery.
viewEnsured(infinite)— Snapshot from ensureOpportunityViews: per-target plan/outcome for the gated Opportunity view creates (action create/noop/refuse in dryRun; created/failed under confirm) + reachability. Audit trail of what swamp created.
viewEnsured2(infinite)— Snapshot from upsertOpportunityView(s): per-view plan/outcome for the gated reconciling Opportunity view writes (create/adopt/reconcile/present/refuse in dryRun; created/adopted/reconciled/present/refuse/failed under confirm) + read-back verification + reachability. Audit trail of what swamp reconciled.
oppAggregates(3d)— Snapshot from aggregateOpportunities: PII-free CRM pipeline analytics (counts + per-currency sums by stage/lineOfBusiness/sourceChannel, open-pipeline, win/loss, active customers) + the read-only GraphQL verification probe. No opp names/ids/contacts.
peopleList(3d)— Snapshot from listPeople: a filtered, paginated, deduped page of compact person views (join keys only, no bulk PII) + continuation/completeness fields
companyList(3d)— Snapshot from listCompanies: a filtered, paginated, deduped page of compact company views (id/name/domain) + continuation/completeness fields
noteList(3d)— Snapshot from listNotes: a filtered, paginated, deduped page of compact note views (no body; title only on the machine 'Inbound lead ' pattern) + continuation/completeness fields
noteDelete(infinite)— Result of a deleteNote run: the resolved target and whether it was deleted
noteBodyRead(3d)— Result of a getNoteBody run: ONE note's bodyV2.markdown (the sanctioned, per-id, confirm-gated SR-1 exception). Short-TTL + generous GC so a migration batch of distinct note-body-<id> instances is not truncated; the 3d TTL is the PII-at-rest bound.
noteWrite(3d)— Result of a note write (createNote/updateNote/appendNote/linkNote/unlinkNote): note id, action, targets linked/failed, and body LENGTH (never the body text). Short-TTL (3d) + generous GC bounds the free-text `title`'s PII-at-rest (S3).
stageOption(infinite)— Result of an ensureStageOption run: the target picklist, the option, the action taken, and the full option set
selectOptionRemoval(infinite)— Result of a removeSelectOption run: the target picklist, the removed value, the action, the (folded) reference count, and the resulting/planned option set. No PII, no emergency split.
fieldEnsured(infinite)— Result of an ensureField run: the field, the action taken (created/present/appended/planned), and any SELECT options added/present + non-mutating drift notes
personUpsert(infinite)— Result of an upsertPerson run: the action taken, which fields were set, and the company link (no raw PII)
objectEnsured(infinite)— Result of an ensureObject run: the custom object's identity, the action taken (created/present/planned-create), and the create payload
relationEnsured(infinite)— Result of an ensureRelation run: both objects' identities, the source/reverse field names, the relation settings/relation read-back, the action taken (created/present/planned-create/type-mismatch), and the create payload