Download all docs
data

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.

Cn
type

Contacts

Person-level address book — people, their ways to reach them, and where they live

dataatomdefinition

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_contact flag 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

PropertyValue
Typecontacts
Categorydata
Formatom
SymbolCn / #9333EA
Storage backendpostgres
LLM roledata

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

CapabilityDescription
person-address-bookPer-circle address book of people with emails, phones, socials, messaging handles, and physical addresses
consent-trackingGDPR-compliant per-endpoint consent tracking (none/soft_optin/double_optin/withdrawn) with structured proof-of-consent evidence (GDPR Art. 7)
do-not-contactMaster 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-gatesfind_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-exportCSV import (with header-row column detection) and flat CSV export for bulk address-book migrations
pending-peer-promotionpending_peers_list / promote_peer promote telephony callers with no matching contact into the address book, atomically repointing conversation threads

Error Codes

CodeClassRetryableDescription
CONTACT_NOT_FOUNDnot_foundNoNo contact matching the provided id, email, phone, or messaging handle
CONTACT_DUPLICATEconflictNoUpsert would create a duplicate — same email already belongs to a different contact id
CONTACT_DO_NOT_CONTACTpolicyNoContact has do_not_contact=true — all outbound blocked regardless of per-channel consent
CONTACT_CONSENT_WITHDRAWNpolicyNoPer-endpoint consent_status=withdrawn — outbound to this endpoint is blocked
CONTACT_SELF_PROTECTEDpolicyNoThe self-contact (circle-anchor row where circle_id=own_circle) is protected and cannot be deleted via the standard delete operation
CONTACTS_CSV_PARSE_ERRORvalidationNoCSV 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

upsertPOST 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.

getPOST get (read)

Fetch a contact with all child groupings (emails, phones, socials, messaging, addresses). Required: id.

listPOST 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 }.

searchPOST 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.

deletePOST delete (write)

Hard-delete a contact and all child rows (GDPR right-to-erasure). Required: id.

tagPOST tag (write)

Add a tag to a contact. Required: id, tag.

untagPOST untag (write)

Remove a tag from a contact. Required: id, tag.

link_organizationPOST link_organization (write)

Attach this contact to an organization (sets organization_id; overwrites any prior link). Required: id, organization_id.

unlink_organizationPOST unlink_organization (write)

Clear the contact’s organization link. Required: id.

set_do_not_contactPOST 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.

statsPOST 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_csvPOST 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_csvPOST 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_emailPOST 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_emailPOST remove_email (write)

Detach an email address from a contact. Required: contact_id, address.

find_by_emailPOST 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_emailPOST verify_email (write)

Mark an email as verified (double-opt-in confirmed); upgrades soft_optin to double_optin. Required: contact_id, address.

set_email_consentPOST 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_phonePOST 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_phonePOST remove_phone (write)

Detach a phone number from a contact. Required: contact_id, number.

find_by_phonePOST 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_listPOST 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_peerPOST 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_phonePOST verify_phone (write)

Mark a phone number as verified (SMS/call confirmation succeeded). Required: contact_id, number.

set_phone_consentPOST 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_socialPOST 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_socialPOST remove_social (write)

Detach a social profile from a contact. Required: contact_id, platform, handle_or_url.

find_by_socialPOST 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_messagingPOST 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_messagingPOST remove_messaging (write)

Detach a messaging handle from a contact. Required: contact_id, platform, handle.

find_by_messagingPOST find_by_messaging (read)

Lookup a contact by messaging handle — used by DM send-gates. Required: platform, handle. Returns { contact, messaging }.

verify_messagingPOST verify_messaging (write)

Mark a messaging handle as verified. Required: contact_id, platform, handle.

set_messaging_consentPOST 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_addressPOST 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_addressPOST 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_addressPOST remove_address (write)

Detach an address from a contact by address_id. Required: contact_id, address_id.

Circle-anchor

get_or_create_selfPOST 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 visiting addresses (summer / winter) or two shipping addresses — so every add creates a new row. Use update_address to 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=true only 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 list or search and 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_name is 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_id scopes to people linked to one organization. tag filters 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_id is omitted) or attaches the phone to an existing one. In both cases, atomically UPDATE-s every conversation with this peer_e164 and contact_id IS NULL to set contact_id = <new id> and clear peer_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 view main.py for 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, and intention are all independently optional. spec MUST 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 from update) 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 by update_element_meta storage 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. The emails, phones, socials, messaging, and addresses arrays 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=true and, when the email was previously at soft_optin, upgrades it to double_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

CodeClassRetryableDescription
CONTACT_NOT_FOUNDnot_foundnoNo contact matching the provided id, email, phone, or messaging handle
CONTACT_DUPLICATEconflictnoUpsert would create a duplicate — same email already belongs to a different contact id
CONTACT_DO_NOT_CONTACTpolicynoContact has do_not_contact=true — all outbound blocked regardless of per-channel consent
CONTACT_CONSENT_WITHDRAWNpolicynoPer-endpoint consent_status=withdrawn — outbound to this endpoint is blocked
CONTACT_SELF_PROTECTEDpolicynoThe self-contact (circle-anchor row where circle_id=own_circle) is protected and cannot be deleted via the standard delete operation
CONTACTS_CSV_PARSE_ERRORvalidationnoCSV 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