Meridian handbook
Destination platform (engine-dmo)

CRM

Organisations, people and the links between them; custom fields on both; the timeline, tags and smart lists of organisations and people; broadcasts to member contacts; the CSV and XLSX import, merging, duplicates, trash and erasure; and every CRM command.

engine-dmo

This feature is part of the DMO bundle (engine-dmo).

The CRM holds the businesses and bodies a destination deals with (organisations) and the people it deals with (people). A person is linked to an organisation as one of its contacts, with a job title and a partner role. Everything partner-facing in engine-dmo is built on these records: memberships are an organisation’s, portal accounts are a person’s, listings belong to an organisation, and reports and broadcasts go to contacts.

CRM records are not documents: they are not localised, have no revisions and no publish state, and never appear on the public site. Staff work with them in the admin under CRM, and through the commands below.

Organisations

FieldNotes
nameRequired
legal_nameOptional
statusprospect, active (the default), lapsed or archived
email, websiteOptional
address, phoneStructured, as the address and phone field types store them
socialA list of social profiles
hoursOpening hours, as the opening_hours field type stores them
lat, lonA point
customThe custom fields you declare in config/crm/organisation.yaml
networkThe network it belongs to; on creation, the default site’s

An organisation also carries its memberships (one per site, see Partners and the portal), its contacts, its tags, its timeline and the documents it owns.

Create one from the command line, for example in a script that sets up a project:

engine-dmo crm organisation create '{"name": "Hook Norton Brewery", "status": "active", "email": "tours@hooknorton.example", "website": "https://www.hooknorton.example"}'

--data - reads the JSON object from standard input. Unknown keys are refused. The keys are name, legal_name, status, email, website, address, phone, social, hours, custom, lat, lon and network (a network key).

People

FieldNotes
given name, family name, display nameDisplay name is what lists and mail use
phonetic given name, phonetic family nameThe reading of each part (furigana), which orders Japanese lists
emailUsed for sign-in to the portal and for mail
phoneStructured
marketing consentWhen it was given and its source; required for broadcasts
customThe custom fields you declare in config/crm/person.yaml
networkAs for organisations

Contacts: linking people to organisations

A contact is a person linked to an organisation. The link survives a change of job: when someone leaves, the link is ended rather than deleted. Each link has:

PropertyMeaning
Job titleFree text, such as “Landlady”
Partner roleWhat the person may do for the organisation in the portal: owner, manager, contact, billing or a role of your own. See Partner roles. A link with no role is contact.
PrimaryThe organisation’s main contact
Digest opt-outThe person has stopped the monthly report digest
Started, endedAn ended link is history; only current links act

Staff add and edit contacts on the organisation’s page. A contact becomes a portal user by accepting an invitation, which staff send from the organisation’s page or with engine-dmo crm invite, or by an approved claim.

Custom fields

Declare the extra fields of organisations and people in config/crm/organisation.yaml and config/crm/person.yaml, with the ordinary field grammar. The admin’s record pages show them in their own group, smart lists filter on them, the import maps columns to them, and portal actions can let members fill them in.

config/crm/organisation.yaml
fields:
  - name: coachParking
    type: boolean
    label: { en-GB: Coach parking }
  - name: preferredContactTime
    type: select
    label: { en-GB: Preferred contact time }
    options:
      - { value: morning }
      - { value: afternoon }
config/crm/person.yaml
fields:
  - name: dietaryNeeds
    type: text
    personal: true
    label: { en-GB: Dietary needs }

Run engine-dmo check --fix to write the ids, then schema plan and schema apply. engine-dmo dev field add organisation coachParking --type boolean does the same in one step.

Values are stored by field id (a select’s by option id), so renaming a field or an option moves nothing.

RuleDetail
Typestext, number, boolean, select, date, datetime, phone, money
Not allowedA core field’s name, translate:, child fields:, visible_when:, unique:, a use: of a shared field group
personal: truePerson fields only. Marks personal data: included in a subject access export and cleared when the person is erased.

engine-dmo config schema --file crm prints the file’s JSON Schema.

The timeline

Every organisation and person has a timeline in the admin: entries the engine records about the record (for example a message the business sent the team from the portal, or a report digest that could not be delivered because the contact’s address is suppressed) and staff notes. A note has a body with mentions, can be pinned, and is either internal (staff only, the default) or member_visible, in which case the organisation’s people see it in the portal and the member API’s activity feed. Mentions notify the staff mentioned.

Tags

Organisations, people and documents share the install’s tag vocabulary (config/tags/vocabulary.yaml, loaded once by engine-dmo tags import and then owned by the admin). Staff with crm.tag.manage tag records on their pages. See engine-dmo tags for the vocabulary, candidates and the tags commands.

Smart lists of organisations and people

A smart list is a saved query that keeps itself up to date. Lists of documents are a core feature (see smartList() and engine-dmo lists); engine-dmo adds lists whose subject is organisation or person. They are used as working lists in the admin, as a broadcast’s audience, and from the command line. They are never public: public: true is refused for them.

Seed lists live in config/tags/smartlists.yaml, which the seed and engine-dmo tags import load once; after that the admin owns them.

config/tags/smartlists.yaml
smart_lists:
  - key: renewal-risk-90
    name: { en-GB: "Renewals due, low engagement" }
    subject: organisation
    query:
      membership: { status: [active], renews_within_days: 90 }
      analytics: { metric: portal_login, period: 180d, lt: 1 }
  - key: coach-friendly
    name: { en-GB: Coach friendly members }
    subject: organisation
    query:
      membership: { tier: [gold] }
      where:
        - { field: custom.coachParking, eq: true }

Query keys that apply to organisation and person lists:

KeyMeaning
tagsTag conditions, as for documents
where, all, any, noneField clauses and groups, as for documents. A custom field is named by its handle or as custom.<name>.
membershipOrganisations (or the people linked to them, or the documents they own) with a membership matching every key given; see below
analyticsOrganisations by an analytics event counted over a period: metric (an event name), period (a number of days such as 180d) and exactly one of lt, lte, gt, gte, eq. Not allowed on a person list. The count includes every event recorded against the organisation, such as portal_login or a click on one of its listings.

membership keys:

KeyMeaning
siteA site key, or any. Defaults to the list’s own site, or the site it is run for.
tierA list of tier keys. none is not allowed: an organisation with no membership has no membership to match.
statusA list of pending, active, lapsed, cancelled
renews_within_daysRenews between today and that many days from now
renews_after_daysRenews more than that many days from now
lapsed_within_daysLapsed, and ended within that many days
joined_before, joined_afterA date or a relative date

A list has at most 50 clauses. Organisations and people are always read within the networks the list’s site and its reader can see.

CommandWhat it does
engine-dmo lists check [--key <key>]Validate every smart list, saved and seeded, against the schema and the vocabulary
engine-dmo lists count --key <key> [--site <site>]How many rows the list matches now
engine-dmo lists run --key <key> --out <file.csv>Write the full set of ids to a CSV file, recorded in the audit log as an export

All three need the database. engine-dmo check does not read config/tags/smartlists.yaml at all, so a seeded list cannot be verified without one: after engine-dmo seed apply (or tags import), run engine-dmo lists check against the database to find a bad key, field or tier.

Broadcasts

A broadcast is a message from staff to the contacts a smart list of organisations or people names, such as every member renewing this quarter. It is sent as marketing mail: a person receives it only with recorded marketing consent, and every message carries an unsubscribe link that cannot be removed.

Staff need broadcast.draft on the site to compose one and broadcast.send to schedule, send or cancel it. The admin’s Broadcasts screen (under Members) shows, before anything is sent, how many people the list reaches and how many it leaves out and why.

Who receives it

The recipients are taken in one step when sending starts. A person the list names is left out, and counted against the reason, when they:

ReasonMeaning
no_consentNo marketing consent is recorded
unsubscribedThey unsubscribed
bouncedTheir address bounced
complainedThey marked mail as spam
suppressedTheir address is on the install’s suppression list
no_emailThey have no address
removedThe record was trashed or erased

An address shared by two recipients receives the message once.

Writing one

The body is Markdown (the public site’s safe subset) or Portable Text. Merge fields are written {{ person.given_name }} and filled in as text, never as markup:

Merge fieldValue
person.display_nameThe recipient’s display name
person.given_nameThe recipient’s given name
organisation.nameThe organisation the recipient is a contact of
membership.tierThat organisation’s tier on the site
membership.renews_onIts renewal date, in the site’s default locale and timezone

A merge field outside this list is refused. Links in the body go through the first-party redirect /_b/c/, so clicks are counted; the unsubscribe link is /_b/u/; and an open is recorded through /_b/o/ only when mail.broadcast.open_tracking is on. The message is rendered with the emails/broadcast.html template, which a project can override in its template root (engine-dmo mail templates shows which file is used).

From the command line, a draft is a YAML or JSON file:

renewals.yaml
subject: Your membership renews soon
preheader: Three things to check before it does
format: markdown
list: renewal-risk-90
body: |
  Hello {{ person.given_name }},

  {{ organisation.name }}'s {{ membership.tier }} membership renews on {{ membership.renews_on }}.
engine-dmo broadcast draft --file renewals.yaml --site main
engine-dmo broadcast show --id <id>
engine-dmo broadcast test-send --id <id> --to you@example.org
engine-dmo broadcast schedule --id <id> --at 2026-12-01T09:00 --confirm

format is markdown (the default) or portabletext, whose body is the blocks as a value or a JSON string. list names a smart list by key or id. draft --id <id> updates that draft.

CommandWhat it does
broadcast list --site <site> [--status <status>]The site’s broadcasts, newest first
broadcast show --id <id>The broadcast, its body and who its list reaches now, with who it leaves out and why
broadcast draft --file <file> [--id <id>] [--site <site>]Write a new draft, or update one
broadcast test-send --id <id> --to <address>Send a test with [Test] before the subject and placeholder merge fields
broadcast schedule --id <id> --at <time> --confirmSchedule it: RFC 3339, or 2006-01-02T15:04 in the site’s timezone
broadcast send --id <id> --confirmSend it now; cannot be taken back
broadcast cancel --id <id>Cancel a draft, a scheduled broadcast, or the rest of one being sent (messages already queued still go)
broadcast report --id <id>Recipients by delivery state, who was left out, unsubscribes, clicks per link and opens

A broadcast’s statuses are draft, scheduled, sending, sent and cancelled.

Sending settings

Key in engine.yamlDefaultMeaning
mail.broadcast.batch_size50Messages queued at a time
mail.broadcast.batch_pause5sWait between batches, on top of the mailer’s own rate limit
mail.broadcast.unsubscribe_footeronCannot be turned off; false is refused
mail.broadcast.open_trackingfalseRecord opens
mail.broadcast.per_recipient_trackingfalseRecord clicks and opens per recipient rather than per broadcast; the footer then says so

Mail delivery itself (the driver, the sender, bounces and the suppression list) is configured under mail:; see Mail.

Importing organisations, people and memberships

The import reads a CSV or XLSX file through a column mapping, matches each row against the records already held, plans every row as a dry run, and writes through the same rules as the admin. Staff with crm.import run it from the admin’s Imports screen (under Members), which saves each source’s mapping so the next file from it maps itself, or from the command line:

engine-dmo crm import --source chamber-2026 --name "Chamber of commerce list" --file members.csv --map mapping.yaml --dry-run
engine-dmo crm import --source chamber-2026 --file members.csv
mapping.yaml
columns:
  Business: organisation.name
  "Trading email": organisation.email
  Postcode: organisation.address.postal_code
  Contact: person.display_name
  "Contact email": person.email
  "Opted in": person.marketing_consent
  Level: membership.tier
  "Renewal date": membership.renews_on
match: [external_id, email, name_postcode]
country: GB
date_order: dmy
site: main
currency: GBP
consent:
  basis: consent
  evidence: The opt-in box on the 2025 membership renewal form
Mapping keyMeaning
columnsColumn heading to target. A column not named, or named to "", is not imported.
matchKeys tried in order to find a record already held: external_id, email, name_postcode. Empty is all three.
countryISO 3166-1 code for addresses and phone numbers whose row names none
date_orderdmy or mdy, to read dates written with slashes or dots. Empty accepts ISO 8601 only.
siteThe site a membership is on when no column names one
currencyA membership amount’s currency when no column names one
consent.basisconsent or soft_opt_in: the lawful source of a marketing consent column
consent.evidenceWhat the person agreed to, where and when; at least 12 characters. Without a documented basis, a consent column is recorded but never becomes marketing consent.

Targets:

EntityTargets
Organisationorganisation.external_id, name, legal_name, status, email, website, phone, address.line1, address.line2, address.locality, address.administrative_area, address.postal_code, address.country, and organisation.custom.<name>
Personperson.external_id, display_name, given_name, family_name, email, phone, job_title, primary, marketing_consent, marketing_consent_date, and person.custom.<name>
Membershipmembership.site, tier, status, joined_on, renews_on, amount, currency, external_ref

--source names the source by key and creates it when new (--name and --network set a new source’s name and network). --map is saved on the source for its next file. --dry-run prints what would be created, updated, skipped and rejected, and writes nothing.

Duplicates, merging and the trash

TaskHow
Find likely duplicatesengine-dmo crm dedupe scores candidate pairs of organisations and offers them for review in the admin. It uses the configured AI provider’s embeddings, which are charged even with --dry-run. See AI assistance.
Merge two recordsIn the admin (Merge under CRM), or engine-dmo crm merge --subject organisation --keep <id> --merge <id> [--dry-run]. Everything that pointed at the merged record moves to the kept one, its blank fields are filled from the merged one, and the merged record goes to the trash.
Trash and restoreOn the record’s page; the trash is under CRM. Trashed records disappear from lists, smart lists and entry.organisation.
PurgePermanently delete from the trash; needs crm.organisation.purge or crm.person.purge.
Erase a personengine-dmo crm erase --person <id> --confirm, irreversibly: their personal columns and personal: true fields are cleared, their sessions and links ended, their address suppressed, and every mention replaced by a tombstone.
Answer a subject access requestengine-dmo sar --email <address> --out <file> writes everything the install holds about the person to one archive, every custom field included. See Data protection duties.

Commands

CommandWhat it does
crm organisation create [json] [--data json]Create an organisation
crm membership set <organisation-id> --tier <tier> [--site] [--status] [--reason]Give an organisation a tier on a site: changes its pending or active membership there, or starts one (active by default)
crm invite --organisation <id> --email <address> --name <name> [--role <job title>] [--site]Link the contact as the organisation’s owner and queue the portal invitation
crm import ...See Importing
crm merge ..., crm dedupe ..., crm erase ...See Duplicates, merging and the trash
crm claims list, crm claims show <id>, crm claims rescoreThe claim queue; see Claims
lists check, lists count, lists runSmart lists
broadcast ...Broadcasts

Every one needs the database, and every one accepts --json. Run engine-dmo crm <command> --help for the details.

Permissions

Staff permissionAllows
crm.organisation.view, .edit, .delete, .purgeOrganisations
crm.person.view, .edit, .delete, .purgePeople
crm.membership.view, .editMemberships on a site
crm.tag.manageTagging records
crm.smartlist.manageSaving and editing smart lists
crm.claim.reviewThe claim queue on a site
crm.importThe import
broadcast.draft, broadcast.sendBroadcasts on a site

The built-in partnership-manager role holds the CRM permissions apart from delete and purge; administrator holds them apart from purge. See What engine-dmo adds.