Contacts
The per-circle address book of people — names, the channels you can reach them on, and where they live — with per-endpoint consent and verification baked in, so every outbound send can check "is this person known, and did they opt in?" before a message goes out.
Working with it
Selecting a Contacts reveals its settings in the properties panel; it has no dedicated full-screen workbench.
How it appears
The same element type rendered as a definition, a circle instance, and a live workspace card.
When to use / not
When to use
- Holding the people a circle communicates with — contacts, leads, members — as the single truth layer for identity and reachability.
- Gating outbound email/SMS/messaging on consent: send-gates query find_by_email / find_by_phone to confirm a recipient opted in to that channel.
- Recording GDPR right-to-object evidence via the do_not_contact flag (reason + source), which overrides every per-channel opt-in.
When not to use
- Cataloguing companies or institutions rather than individuals — use data/organizations, the account-side counterpart, and link contacts to it.
- Storing free-form documents or arbitrary records — that is what data/document and data/entity are for; contacts is a structured person model.
Topology
Created from the library and placed inside an app or circle. It is a top-level building block you compose with other elements.
Properties
No configurable properties.
Operations
- activityGET
- add_addressPOST
- add_emailPOST
- add_messagingPOST
- add_phonePOST
- add_socialPOST
- attachmentsGET
- batch_statsGET
- composePOST
- contextGET
- createPOST
- deletePOST
- disablePOST
- enablePOST
- export_bundleGET
- export_csvPOST
- find_by_emailPOST
- find_by_messagingPOST
- find_by_phonePOST
- find_by_socialPOST
- getPOST
- get_or_create_selfPOST
- import_bundlePOST
- import_csvPOST
- intentionGET
- link_organizationPOST
- listPOST
- pending_peers_listPOST
- promotePOST
- promote_peerPOST
- readmeGET
- readme_updatePOST
- remove-modifierPOST
- remove_addressPOST
- remove_emailPOST
- remove_messagingPOST
- remove_phonePOST
- remove_socialPOST
- restorePOST
- schemaGET
- searchPOST
- set_do_not_contactPOST
- set_email_consentPOST
- set_messaging_consentPOST
- set_phone_consentPOST
- sourceGET
- source_branchesGET
- source_fixturesPOST
- source_promotePOST
- source_repairPOST
- source_statusGET
- source_validatePOST
- statsPOST
- tagPOST
- treeGET
- unlink_organizationPOST
- untagPOST
- updatePATCH
- update_addressPOST
- update_metaPATCH
- upsertPOST
- verify_emailPOST
- verify_messagingPOST
- verify_phonePOST
- versionGET
Composition
Contacts (contacts)
Category: data | Form: | Symbol: Cn
Person-level address book — people, their ways to reach them, and where they live
Per-circle address book of individual people. Each contact has a name, optional avatar, optional link to an organization (data/organizations), tags, custom JSON, and five child groupings of contact details:
• emails — one or more email addresses (with per-address consent, verification, engagement signals, bounce counts) • phones — phone numbers in E.164 format (with per-number consent for SMS / voice outreach) • socials — public social profiles (linkedin, x, github, instagram, facebook, …) — no consent; they’re pointers, not reachable channels • messaging — direct-messaging handles (whatsapp, telegram, signal, matrix, discord, slack, …) — per-handle consent, same shape as emails/phones • addresses — physical addresses (postal, billing, shipping, visiting, residential) with street / city / region / postal_code / country
Contacts are the truth layer. Noisy, unverified research rows live in data/leads; they promote into Contacts (plus an Organization when a company is involved) once qualified. Outbound channel gates (email’s unknown-recipient brake, SMS’s future equivalent) query this element via find_by_email / find_by_phone / find_by_messaging to decide whether a recipient is known to the circle and has given the right consent for the channel being used. A contact-level
do_not_contactflag sits ABOVE per-endpoint consent: when set, every send-gate refuses outbound of any channel regardless of per-channel opt-in. Set via set_do_not_contact (records reason and source for GDPR Art. 21 right-to-object evidence). Per-endpoint consent still exists for channel-specific opt-out flows (“stop emailing me but keep calling”) but DNC is the master switch. The backing per-circle schema (circle_{uuid}.contacts plus its five child tables) is auto-bootstrapped on first upsert — no migration step. CSV import/export for bulk loads.
Guide
Person-level address book — people, their ways to reach them, and where they live
What It Does
Contacts is a per-circle address book of individual people. Each contact carries a name, optional avatar, optional link to an organization (data/organizations), tags, custom JSON, and five child groupings of contact details: emails, phones, socials, messaging handles, and physical addresses. Emails, phones, and messaging handles track per-endpoint consent and verification; socials are public profile pointers with no consent (they are not reachable channels); addresses are postal/billing/shipping/visiting/residential rows.
Contacts are the truth layer. The backing per-circle schema (circle_{uuid}.contacts plus the sibling tables contact_emails, contact_phones, contact_socials, contact_messaging, contact_addresses) is auto-bootstrapped on first upsert — there is no migration step. The element spec itself holds no per-row data; it is the identity anchor for the address book, and all rows live in the per-circle Postgres tables.
Outbound channel send-gates query this element through find_by_email / find_by_phone / find_by_messaging to decide whether a recipient is known to the circle and has the right consent for the channel being used. A contact-level do_not_contact flag sits above per-endpoint consent: when set, every send-gate refuses outbound of any channel regardless of per-channel opt-in. It is set via set_do_not_contact, which records reason and source for GDPR Art. 21 right-to-object evidence.
Element Definition
| Property | Value |
|---|---|
| Type | contacts |
| Category | data |
| Form | atom |
| Symbol | Cn / #9333EA |
| Storage backend | postgres |
| LLM role | data |
Properties
This element has no configurable spec fields (properties.fields: {}, required: []). The element spec is the namespace/identity anchor for the per-circle address book; contact rows live in per-circle Postgres tables, not in the spec. All contact data is created and mutated through the operations below.
Ports
None. data/contacts is a data-atom — wirable: false, no flow ports, no child bonds, no attached modifiers. It references data/organizations via the organization_id foreign-key relationship (uses: [organizations]).
Capabilities
| Capability | Description |
|---|---|
person-address-book | Per-circle address book of people with emails, phones, socials, messaging handles, and physical addresses |
consent-tracking | GDPR-compliant per-endpoint consent tracking (none/soft_optin/double_optin/withdrawn) with structured proof-of-consent evidence (GDPR Art. 7) |
do-not-contact | Master per-contact do-not-contact switch that blocks all outbound regardless of per-endpoint consent; records reason and source for GDPR Art. 21 right-to-object evidence |
outbound-send-gates | find_by_email / find_by_phone / find_by_messaging lookup ops used by io/email and io/phone-number send-gates to decide whether a recipient is known and consented |
bulk-import-export | CSV import (with header-row column detection) and flat CSV export for bulk address-book migrations |
pending-peer-promotion | pending_peers_list / promote_peer promote telephony callers with no matching contact into the address book, atomically repointing conversation threads |
Error Codes
| Code | Class | Retryable | Description |
|---|---|---|---|
CONTACT_NOT_FOUND | not_found | No | No contact matching the provided id, email, phone, or messaging handle |
CONTACT_DUPLICATE | conflict | No | Upsert would create a duplicate — same email already belongs to a different contact id |
CONTACT_DO_NOT_CONTACT | policy | No | Contact has do_not_contact=true — all outbound blocked regardless of per-channel consent |
CONTACT_CONSENT_WITHDRAWN | policy | No | Per-endpoint consent_status=withdrawn — outbound to this endpoint is blocked |
CONTACT_SELF_PROTECTED | policy | No | The self-contact (circle-anchor row where circle_id=own_circle) is protected and cannot be deleted via the standard delete operation |
CONTACTS_CSV_PARSE_ERROR | validation | No | CSV input could not be parsed — check header row and encoding |
Operations
All operations are POST to …/ops/<path>. Auth level is per-operation (read / write).
Core contact
upsert — POST upsert (write)
Create or update a contact — merges child groupings by natural key. Idempotent on id (when provided) or on a deterministic match against any provided email / phone. The emails, phones, socials, messaging, and addresses arrays upsert their rows by natural key; rows not named are preserved. Tags union; custom JSON merges shallowly. Required input: name.
get — POST get (read)
Fetch a contact with all child groupings (emails, phones, socials, messaging, addresses). Required: id.
list — POST list (read)
Page through contacts (newest first by default). Optional limit (default 100), offset (default 0), organization_id (scope to one organization), tag (filter by tag). Returns { contacts, total }.
search — POST search (read)
Free-text substring search across name, email, phone, title, notes, and messaging handles. Required: query; optional limit (default 50). Returns { contacts } with children populated.
delete — POST delete (write)
Hard-delete a contact and all child rows (GDPR right-to-erasure). Required: id.
tag — POST tag (write)
Add a tag to a contact. Required: id, tag.
untag — POST untag (write)
Remove a tag from a contact. Required: id, tag.
link_organization — POST link_organization (write)
Attach this contact to an organization (sets organization_id; overwrites any prior link). Required: id, organization_id.
unlink_organization — POST unlink_organization (write)
Clear the contact’s organization link. Required: id.
set_do_not_contact — POST set_do_not_contact (write)
Set or clear the master do-not-contact switch. When true, every send-gate refuses outbound regardless of per-endpoint consent. Records reason + source + timestamp for GDPR Art. 21 evidence. Required: id, value (boolean); optional reason, source.
stats — POST stats (read)
Aggregate counts — total_contacts, with_organization, per-channel totals (total_emails, total_phones, total_socials, total_messaging, total_addresses), per-consent-status counts (emails_soft_optin, emails_double_optin, emails_withdrawn, and phone equivalents), and engaged_30d. No required input.
import_csv — POST import_csv (write)
Bulk import contacts from a CSV string (first row is a header). Recognised columns: name, first_name, last_name, title, email, phone, organization_name, tags (semicolon-separated), consent_status, consent_source, notes. When organization_name is present, the importer looks up the matching organization in data/organizations — it does NOT create organizations. Required: csv. Returns { created, updated, skipped, not_linked_org }.
export_csv — POST export_csv (read)
Export the address book as a flat, denormalised CSV — one row per contact, multi-value child groupings joined (emails/phones semicolon-separated). Optional limit (default 10000, max 100000). Returns { csv, rows }.
Emails
add_email — POST add_email (write)
Attach an email address to a contact. Idempotent on (contact_id, address); re-adding merges fields. Required: contact_id, address; optional label, primary, verified, consent_status, consent_source, consent_evidence.
remove_email — POST remove_email (write)
Detach an email address from a contact. Required: contact_id, address.
find_by_email — POST find_by_email (read)
Lookup a contact by email address — used by outbound send-gates. Required: address. Returns { contact, email } (the matched contact plus the specific email row).
verify_email — POST verify_email (write)
Mark an email as verified (double-opt-in confirmed); upgrades soft_optin to double_optin. Required: contact_id, address.
set_email_consent — POST set_email_consent (write)
Record consent state for a specific email address (consent_status, consent_source, consent_evidence, consent_at=now). Required: contact_id, address, consent_status.
Phones
add_phone — POST add_phone (write)
Attach a phone number to a contact. Normalised to E.164 on insert; idempotent on (contact_id, normalised_number). Required: contact_id, number; optional label, primary, verified, consent_status, consent_source, consent_evidence.
remove_phone — POST remove_phone (write)
Detach a phone number from a contact. Required: contact_id, number.
find_by_phone — POST find_by_phone (read)
Lookup a contact by phone number (normalised to E.164 first) — used by SMS / voice send-gates. Required: number. Returns { contact, phone }.
pending_peers_list — POST pending_peers_list (read)
List unidentified telephony peers — distinct caller phone numbers with telephony conversations but no matching Contact yet. Each row carries peer_e164, conversation_count, turn_count, last_activity_at, last_preview. Optional limit (default 100). Returns { peers }.
promote_peer — POST promote_peer (write)
Promote a pending peer to a Contact — creates a new contact (when contact_id is omitted) or attaches the phone to an existing one, then atomically repoints every conversation with that peer_e164 to the contact. Idempotent. Required: peer_e164; optional contact_id, name. Returns { contact_id, created_contact, conversations_attached }.
verify_phone — POST verify_phone (write)
Mark a phone number as verified (SMS/call confirmation succeeded). Required: contact_id, number.
set_phone_consent — POST set_phone_consent (write)
Record consent state for a specific phone number. Required: contact_id, number, consent_status; optional consent_source, consent_evidence.
Socials
Public profile pointers — no consent, not reachable channels.
add_social — POST add_social (write)
Attach a social profile (LinkedIn, X, GitHub, Instagram, …) to a contact. Idempotent on (contact_id, platform, handle_or_url); platform is an open string. Required: contact_id, platform, handle_or_url; optional label.
remove_social — POST remove_social (write)
Detach a social profile from a contact. Required: contact_id, platform, handle_or_url.
find_by_social — POST find_by_social (read)
Lookup a contact by social profile (platform + handle/URL). Required: platform, handle_or_url. Returns { contact, social }.
Messaging
Direct-messaging handles — same consent shape as emails/phones.
add_messaging — POST add_messaging (write)
Attach a messaging handle (WhatsApp, Telegram, Signal, Matrix, Discord, Slack, …). Idempotent on (contact_id, platform, handle); platform is an open string. Consent applies. Required: contact_id, platform, handle; optional label, verified, consent_status, consent_source, consent_evidence.
remove_messaging — POST remove_messaging (write)
Detach a messaging handle from a contact. Required: contact_id, platform, handle.
find_by_messaging — POST find_by_messaging (read)
Lookup a contact by messaging handle — used by DM send-gates. Required: platform, handle. Returns { contact, messaging }.
verify_messaging — POST verify_messaging (write)
Mark a messaging handle as verified. Required: contact_id, platform, handle.
set_messaging_consent — POST set_messaging_consent (write)
Record consent state for a specific messaging handle. Required: contact_id, platform, handle, consent_status; optional consent_source, consent_evidence.
Addresses
Physical addresses — one contact can have many, keyed by address_id.
add_address — POST add_address (write)
Attach a physical address to a contact. Every add creates a new row (keyed by a generated address_id). Required: contact_id; optional kind (postal | billing | shipping | visiting | residential), street1, street2, city, region, postal_code, country (ISO-3166 alpha-2), label.
update_address — POST update_address (write)
Update one address on a contact by address_id. Required: contact_id, address_id; optional kind, street1, street2, city, region, postal_code, country, label.
remove_address — POST remove_address (write)
Detach an address from a contact by address_id. Required: contact_id, address_id.
Circle-anchor
get_or_create_self — POST get_or_create_self (read)
Resolve-or-create the self-contact for the calling circle — the row where circle_id == this_circle_id. Idempotent; bootstrapped automatically on circle creation. The name comes from the circle’s display_name (or name fallback). The self-contact cannot be deleted via delete (protected by a per-circle BEFORE-DELETE trigger). No required input. Returns { id, circle_id, name }.
Quick Start
Create the element
POST /api/{circle}/{project}/
Content-Type: application/json
{
"element_type": "contacts",
"slug": "address-book",
"name": "Address Book",
"spec": {}
}
Upsert a contact
POST /api/{circle}/{project}/address-book/ops/upsert
Content-Type: application/json
{
"name": "Jane Smith",
"email": "jane@example.com",
"title": "CTO",
"tags": ["customer"],
"phones": [
{ "number": "+46701234567", "label": "mobile", "consent_status": "soft_optin" }
]
}
Check a recipient at a send-gate
POST /api/{circle}/{project}/address-book/ops/find_by_email
Content-Type: application/json
{ "address": "jane@example.com" }
The response carries { contact, email }; the gate inspects the returned email row’s consent_status (and the contact’s do_not_contact flag) before sending.
Common Mistakes
Passing partial child arrays to upsert expecting a full replace.
The emails/phones/socials/messaging/addresses arrays UPSERT by natural key — rows not named in the array are preserved, not deleted. upsert does not do a full replace.
Treating do_not_contact as one of the per-endpoint consent states.
do_not_contact is the master switch and sits above per-endpoint consent. When true, every channel is blocked regardless of any endpoint’s consent_status. Use set_do_not_contact (not a per-endpoint consent op) for the unsubscribe-from-all / GDPR-objection path, so reason and source are recorded as evidence.
Expecting import_csv to create organizations.
When a row carries organization_name, the importer only links to an existing data/organizations row — it never creates organizations. Import organizations first (via data/organizations’ own import_csv) if needed; unmatched rows are reported in not_linked_org.
Deleting the self-contact.
The circle-anchor self-contact (circle_id == own_circle) is protected by a BEFORE-DELETE trigger and returns CONTACT_SELF_PROTECTED from delete.
Relationships
- Uses: organizations
Capabilities
- person-address-book: Per-circle address book of people with emails, phones, socials, messaging handles, and physical addresses
- consent-tracking: GDPR-compliant per-endpoint consent tracking (none/soft_optin/double_optin/withdrawn) with structured proof-of-consent evidence (GDPR Art. 7)
- do-not-contact: Master per-contact do-not-contact switch that blocks all outbound regardless of per-endpoint consent; records reason and source for GDPR Art. 21 right-to-object evidence
- outbound-send-gates: find_by_email / find_by_phone / find_by_messaging lookup ops used by io/email and io/phone-number send-gates to decide whether a recipient is known and consented
- bulk-import-export: CSV import (with header-row column detection) and flat CSV export for bulk address-book migrations
- pending-peer-promotion: pending_peers_list / promote_peer promote telephony callers with no matching contact into the address book, atomically repointing conversation threads
Operations
activity
Get /ops/activity | Auth: Read
Get activity events for this element
Scope depends on element capabilities: individual elements query by element_id, project-form elements with activity-scope-members include member activities, circle-level elements with activity-scope-all query the entire circle. Gracefully returns empty list if activities table is missing (old circles).
add_address
Post /ops/add_address | Auth: Write
Attach a physical address to a contact
Unlike emails/phones (unique by content), addresses are keyed by a generated address_id. A contact can reasonably have two
visitingaddresses (summer / winter) or twoshippingaddresses — so every add creates a new row. Useupdate_addressto modify an existing one.
add_email
Post /ops/add_email | Auth: Write
Attach an email address to a contact
Idempotent on (contact_id, address). Re-adding an existing email merges the provided fields — use this to update a label or consent state. Sets
primary=trueonly when explicitly passed; if no email is marked primary the first-added wins by insertion order.
add_messaging
Post /ops/add_messaging | Auth: Write
Attach a messaging handle (WhatsApp, Telegram, Signal, Matrix, Discord, Slack, …)
Idempotent on (contact_id, platform, handle). Platform is an open string — recommended set: whatsapp, telegram, signal, matrix, discord, slack, wechat, line. Consent applies — DMing a WhatsApp handle without opt-in is the same category of problem as cold-emailing.
add_phone
Post /ops/add_phone | Auth: Write
Attach a phone number to a contact
Numbers are normalised to E.164 (+46701234567) on insert. Idempotent on (contact_id, normalised_number). Consent applies to both SMS and voice — per-channel consent split (SMS-yes / voice-no) is a future concern; today the number either is or isn’t reachable.
add_social
Post /ops/add_social | Auth: Write
Attach a social profile (LinkedIn, X, GitHub, Instagram, …) to a contact
Idempotent on (contact_id, platform, handle_or_url). Platform is an open string — the recommended set is linkedin, x, github, instagram, facebook, mastodon, threads, bluesky, youtube, tiktok, but any value is accepted. Socials are pointers, not channels; no consent fields.
attachments
Get /ops/attachments | Auth: Read
List all modifiers and resources attached to this element
Returns both modifiers (policy enforcement) and resources (data injection) with is_modifier flag to distinguish. Items in the generated MODIFIER_TYPES list are modifiers; everything else is a resource. Includes cascade_policy and version pin info.
batch_stats
Get /ops/batch_stats | Auth: Read
Get per-element statistics for all children of this element
Returns per-child stats plus an aggregate. Most meaningful on compound or manifest form elements (repositories, circles, projects); atoms have no children so the result is an empty children array with a zeroed aggregate. Uses efficient GROUP BY SQL. Weighted averages for eval scores.
compose
Post /ops/compose | Auth: Execute
Batch add and remove modifiers on this element in a single call
Declarative composition: add modifiers by ref path (slug or path@version) and remove by attachment ID, all in one atomic call on the target element. Each ‘add’ entry resolves the source element, validates topology, attaches with optional priority and cascade policy. Each ‘remove’ entry deletes the attachment row. Returns a summary of what was added and removed. Example: compose({ add: [{ref: “my-prompt”}, {ref: “rate-limit/api@v2”, priority: 50}], remove: [{attachment_id: “uuid”}] })
context
Get /ops/context | Auth: Read
Get connected elements (graph traversal)
Graph traversal showing all connected elements with their relationship type (contains, contained_by, references, referenced_by, attaches, etc.). Use ?depth=N to control traversal depth (default 1) and ?types=actor,data to filter by element types.
create
Post /ops/create | Auth: Write
Create child element
POST to the parent path — element_type goes in the request body, NOT the URL. Both element_type and slug are required and must be non-empty. Name is derived from slug if omitted. Writes to both Git and PostgreSQL. All elements are stored flat under the circle — no intermediate library wrapper rows.
delete
Post /ops/delete | Auth: Write
Delete a contact and all child rows (GDPR right-to-erasure)
Hard delete — removes the contact row and all its emails, phones, socials, messaging handles, and addresses. Email / SMS event logs elsewhere in the circle keep addresses for audit purposes under their own retention policy.
disable
Post /ops/disable | Auth: Admin
Disable element (hides and prevents use)
Idempotent — safe to call on already-disabled elements. Optionally pass a reason string. Disabled elements cannot be invoked or executed. Inverse of enable.
enable
Post /ops/enable | Auth: Admin
Enable element (makes usable and visible)
Idempotent — safe to call on already-enabled elements. Transitions element to ready/enabled state. Cannot enable deleted elements. Inverse of disable.
export_bundle
Get /ops/export/bundle | Auth: Read
Export element as downloadable git bundle
On non-root-namespace elements, returns a binary git bundle. On root-namespace (circle) elements, dispatch hands off to the circle’s own export_bundle op, which returns a multi-element JSON envelope with one base64 bundle per child element — this is intentional, not an error.
export_csv
Post /ops/export_csv | Auth: Read
Export the address book as CSV
Flat denormalised shape — one row per contact. Multi-value child groupings are joined: emails semicolon-separated, phones semicolon-separated, etc. For a structured export (one row per email, one per phone, etc.) fetch the contacts via
listorsearchand format client-side. Default limit 10 000, max 100 000.
find_by_email
Post /ops/find_by_email | Auth: Read
Lookup a contact by email address — used by outbound send-gates
Returns the matched contact (with all children) plus the specific email row that matched. Outbound email’s unknown-recipient brake calls this to decide whether an address is known to the circle; campaign sends also check the returned email row’s consent_status.
find_by_messaging
Post /ops/find_by_messaging | Auth: Read
Lookup a contact by messaging handle — used by DM send-gates
find_by_phone
Post /ops/find_by_phone | Auth: Read
Lookup a contact by phone number — used by SMS / voice send-gates
Number is normalised to E.164 before lookup. Returns the matched contact (with all children) plus the phone row that matched. SMS / voice outbound gates should use this the way email uses find_by_email.
find_by_social
Post /ops/find_by_social | Auth: Read
Lookup a contact by social profile (platform + handle/URL)
Useful when an inbound signal carries a social handle (e.g. a LinkedIn reply webhook, a GitHub mention) and you want to correlate it back to a known contact.
get
Post /ops/get | Auth: Read
Fetch a contact with all child groupings (emails, phones, socials, messaging, addresses)
get_or_create_self
Post /ops/get_or_create_self | Auth: Read
Resolve-or-create the self-contact for the calling circle
Idempotent. Returns the contact representing the calling circle in its own address book — the row where
circle_id == this_circle_id. Bootstrapped automatically on circle creation; this op is the explicit lookup path for chat handlers and any caller that needs the canonical “self” contact id. The contact’s name comes from the circle’s display_name (or name fallback). Cannot be deleted via normal contacts.delete — protected by a per-circle BEFORE-DELETE trigger.
import_bundle
Post /ops/import/bundle | Auth: Write
Import git bundle into element
Accepts a base64-encoded git bundle in the JSON bundle_base64 field. Use overwrite=true to replace existing elements with same slug (default skips duplicates). Imported elements get new UUIDs. Returns counts of imported/skipped elements and any errors.
import_csv
Post /ops/import_csv | Auth: Write
Bulk import contacts from a CSV string
First row must be a header. Recognised columns: name, first_name, last_name, title, email, phone, organization_name, tags (semicolon-separated), consent_status, consent_source, notes. Each row becomes a Contact (with one email and/or phone child row if those columns were populated). When
organization_nameis present, the importer looks up the matching organization in data/organizations — it does NOT create organizations (that’s a separate decision; use data/organizations’ import_csv first if needed). Returns counts (created, updated, skipped, not_linked_org).
intention
Get /ops/intention | Auth: Read
Get element intention with full inheritance chain
Returns three levels: direct (this element’s intention), inherited (from category and root), and resolved (final merged intention). Useful for understanding an element’s purpose in context of its hierarchy.
link_organization
Post /ops/link_organization | Auth: Write
Attach this contact to an organization (current employer, affiliation)
Sets the contact’s
organization_id. If the contact was already linked to a different organization, the previous link is overwritten (many-to-one for now; job-history tracking is a future concern).
list
Post /ops/list | Auth: Read
Page through contacts (newest first by default)
Without filters, returns the whole address book newest-first.
organization_idscopes to people linked to one organization.tagfilters to contacts carrying that tag. Both can be combined.
pending_peers_list
Post /ops/pending_peers_list | Auth: Read
List unidentified telephony peers (callers who aren’t contacts yet)
The C-deep “pending peers” surface. Returns one row per distinct peer phone number for which the circle has telephony conversations (voice transcripts, SMS) but no matching Contact yet. Each row carries aggregate stats (turn count, last activity, last preview) so the UI can render “this number tried to reach me, here’s the gist” cards. Backed by
idx_conversations_pending_peer_agent_active(migration 0084) — query is O(pending peers count).
promote
Post /ops/promote | Auth: Admin
Promote element configuration to a target environment
Only for manifest-form elements (projects). Environments advance: dev → demo → live. dev→demo requires member+ role, demo→live requires admin. Freezes member versions at promotion time (creates snapshot). Persists environment config to spec.environments.
promote_peer
Post /ops/promote_peer | Auth: Write
Promote a pending peer to a Contact — atomically attaches all matching conversations
Either creates a new Contact (when
contact_idis omitted) or attaches the phone to an existing one. In both cases, atomically UPDATE-s every conversation with thispeer_e164andcontact_id IS NULLto setcontact_id = <new id>and clearpeer_e164— moving the pending-peer thread(s) into the contact’s normal timeline. Idempotent: re-running with the same inputs is a no-op since the WHERE clause matches nothing on the second run.
readme
Get /ops/readme | Auth: Read
Get element README.md content
Reads README.md from the element’s git repository. Returns empty content (not an error) if no README exists. Always returns markdown format.
readme_update
Post /ops/readme_update | Auth: Write
Update element README.md content
Creates or overwrites README.md in the element’s git repo. Commits to the draft branch. Content must be provided as a markdown string.
remove-modifier
Post /ops/remove-modifier | Auth: Execute
Remove an attached modifier from this element by attachment ID
Removes a modifier/resource attachment by its row ID. The ID comes from the attachments or context API. This is the reverse of attach — called on the target element, not the source.
remove_address
Post /ops/remove_address | Auth: Write
Detach an address from a contact by address_id
remove_email
Post /ops/remove_email | Auth: Write
Detach an email address from a contact
remove_messaging
Post /ops/remove_messaging | Auth: Write
Detach a messaging handle from a contact
remove_phone
Post /ops/remove_phone | Auth: Write
Detach a phone number from a contact
remove_social
Post /ops/remove_social | Auth: Write
Detach a social profile from a contact
restore
Post /ops/restore | Auth: Admin
Restore element to a specific version
Automatically snapshots the current state before restoring (creates a ‘Before restore to vN’ version entry). Writes restored spec to git as .triform/spec.yaml. Git failures warn but don’t fail the operation — DB state is authoritative. Cannot restore deleted elements.
schema
Get /ops/schema | Auth: Read
Get element input/output schema (MCP tools/list compatible)
Returns type-level port schemas from the TypeRegistry — not instance-specific overrides. Includes direction (input/output), required flag, and JSON schema per port. Useful for understanding what data an element accepts and produces.
search
Post /ops/search | Auth: Read
Free-text search across name, email, phone, title, notes
Substring match across the contact row’s name fields, plus any email address, phone number, or messaging handle attached. Use this for the global address-book search bar. Returns matched contacts with all children populated.
set_do_not_contact
Post /ops/set_do_not_contact | Auth: Write
Set or clear the master do-not-contact switch on a contact
DNC sits above per-endpoint consent: when true, every send-gate (email, SMS, voice, WhatsApp, …) refuses outbound regardless of whether individual emails/phones/messaging rows are opted in. Records reason + source + timestamp so GDPR Art. 21 requests have evidence of when the objection was registered. Typical flow: • User clicks ‘unsubscribe from all’ in a footer → set_do_not_contact(id, value=true, reason=‘unsubscribe’, source=‘email_reply’) • GDPR objection letter received → set_do_not_contact(id, value=true, reason=‘gdpr_objection’, source=‘operator’) • Operator sets by mistake → set_do_not_contact(id, value=false) clears it. DNC is not legally one-way; the field is the current state and the evidence lives in email_events / activity logs elsewhere.
set_email_consent
Post /ops/set_email_consent | Auth: Write
Record consent state for a specific email address
Writes consent_status, consent_source, consent_evidence, and consent_at (= now). Use this when capturing consent outside of a verify round-trip — e.g. a web form submission, a CSV import that carried consent columns, an OAuth scope grant.
set_messaging_consent
Post /ops/set_messaging_consent | Auth: Write
Record consent state for a specific messaging handle
set_phone_consent
Post /ops/set_phone_consent | Auth: Write
Record consent state for a specific phone number
source
Get /ops/source | Auth: Read
Get any file’s content from the element’s git repository
Reads an arbitrary file from the element’s CAS-backed git tree by its relative path. Same store as
readme, just generalized. Path safety: rejects..traversal, leading/, and null bytes. Use this to viewmain.pyfor action elements, asset files for SPAs, etc. Returns empty content (not an error) if the file doesn’t exist.
source_branches
Get /ops/source/branches | Auth: Read
List Source branches for this element
Returns the standard draft/demo/live Source branches, their current commits, and promotion relationships. Use GET /api/{element_path}/ops/source/branches.
source_fixtures
Post /ops/source/fixtures | Auth: Write
Dry-run or apply approved Source seed fixtures
Scans
.triform/fixtures/manifests from the addressed data element Source repo. Defaults to dry_run=true and never imports live runtime data. Apply requires dry_run=false plus confirm=true and dispatches approved records through existing generated element ops.
source_promote
Post /ops/source/promote | Auth: Write
Promote Source branch forward
Promotes draft to demo or demo to live through the generated element op path. Direct Git pushes to demo/live are blocked by Source policy.
source_repair
Post /ops/source/repair | Auth: Write
Inspect or repair the element Source index
Runs Source repair through the element operation path. Defaults to dry_run=true; set dry_run=false only after reviewing a dry-run report.
source_status
Get /ops/source/status | Auth: Read
Get Source control status for this element
Returns the branch-aware clone URL, checkout commands, current draft commit, child source-link count, portable export summary, Source health, warnings, and auth hints for the addressed element. Use the element-first path: GET /api/{element_path}/ops/source/status.
source_validate
Post /ops/source/validate | Auth: Read
Validate Source branch contents
Validates a Source branch before accepting local Git workflow changes or promotion. Defaults to branch=draft and rejects runtime data, generated output, secret material, and unreadable CAS refs.
stats
Post /ops/stats | Auth: Read
Aggregate counts — totals, per-channel, per-consent-status, recently-engaged
tag
Post /ops/tag | Auth: Write
Add a tag to a contact
tree
Get /ops/tree | Auth: Read
Get the element’s position in the graph — ancestors, children, references, and subtree statistics
Uses per-circle ElementGraph cache for O(1) lookups. Returns ancestors (containment chain), children (direct), members (references), referenced_by (reverse refs), attachments, and subtree stats. Default depth is 3, max is 10. Pass ?include_metadata=true for name/state on each node.
unlink_organization
Post /ops/unlink_organization | Auth: Write
Clear the contact’s organization link
untag
Post /ops/untag | Auth: Write
Remove a tag from a contact
update
Patch /ops/update | Auth: Write
Update element
Partial update — send only the fields you want to change.
spec,name, andintentionare all independently optional.specMUST be a JSON object when present; deep-merged into the existing spec by default. Empty{"spec":{}}preserves existing spec content but still records a new version (no-op for content, not for version state). To clear/replace the entire spec wholesale send{"spec":{...},"deep":false}. List-typed spec fields use replace semantics (the patch list replaces the existing list, no array merging). Coordinates Git + DB writes. Slug cannot be changed after creation.
update_address
Post /ops/update_address | Auth: Write
Update one address on a contact by address_id
update_meta
Patch /ops/update_meta | Auth: Write
Update element metadata (lightweight merge — does NOT bump version or snapshot spec)
Shallow JSONB merge into element.meta. Top-level keys in the provided value replace existing meta values; other keys are preserved. Used for UI metadata like canvas positions, panel state, viewer preferences. Wire-shape op_name is
update_meta(distinct fromupdate) so SSE subscribers + the cache auto-invalidator can distinguish lightweight metadata changes from spec edits without inspecting the payload. The MutatingElementStore wrapper stamps this op_name on the lifecycle event emitted byupdate_element_metastorage calls.
upsert
Post /ops/upsert | Auth: Write
Create or update a contact — merges child groupings by natural key
Idempotent on
id(when provided) or on a deterministic match against any provided email / phone. Theemails,phones,socials,messaging, andaddressesarrays UPSERT their rows — existing rows matching on natural key (email address, phone number, social platform+handle, messaging platform+handle, or address_id) are merged; new rows are inserted. Rows NOT named in the arrays are preserved (pass the full arrays only when you want to do a full replace via a separate sync op — not this one). Tags union with existing tags. Custom JSON merges shallowly at top level.
verify_email
Post /ops/verify_email | Auth: Write
Mark an email address as verified (double-opt-in confirmed)
Flips
verified=trueand, when the email was previously atsoft_optin, upgrades it todouble_optin. Use at the tail end of a confirmation-link round-trip.
verify_messaging
Post /ops/verify_messaging | Auth: Write
Mark a messaging handle as verified
verify_phone
Post /ops/verify_phone | Auth: Write
Mark a phone number as verified (SMS/call confirmation succeeded)
version
Get /ops/version | Auth: Read
Get current version or full history
Returns current version by default. Pass ?history=true for full version history (up to ?limit=N, default 50). Versions are backed by the element_versions table. Every spec update creates a new version entry.
Error Codes
| Code | Class | Retryable | Description |
|---|---|---|---|
CONTACT_NOT_FOUND | not_found | no | No contact matching the provided id, email, phone, or messaging handle |
CONTACT_DUPLICATE | conflict | no | Upsert would create a duplicate — same email already belongs to a different contact id |
CONTACT_DO_NOT_CONTACT | policy | no | Contact has do_not_contact=true — all outbound blocked regardless of per-channel consent |
CONTACT_CONSENT_WITHDRAWN | policy | no | Per-endpoint consent_status=withdrawn — outbound to this endpoint is blocked |
CONTACT_SELF_PROTECTED | policy | no | The self-contact (circle-anchor row where circle_id=own_circle) is protected and cannot be deleted via the standard delete operation |
CONTACTS_CSV_PARSE_ERROR | validation | no | CSV input could not be parsed — check header row and encoding |
Observability
Defined for this element
Metrics
- contacts_inserted_count
- contacts_updated_count
- contacts_deleted_count
- contacts_query_count
- contacts_query_latency_ms
Events
- contacts.contact.inserted
- contacts.contact.updated
- contacts.contact.deleted
- contacts.query.executed
Pricing / cost
Platform default
Operation costs
- create: free
- update: free
- delete: free
- get: free
- list: free
- invoke: 10000 micro-AU
- tool_use: free