Skip to content

Read and propose CRM changes

ctx.crm is one API over the tenant’s CRM, whichever it is. It speaks in Motions’ own field names, such as first_name and tax_id, and crm.yaml maps them to your CRM’s properties. A step written against it works on the test CRM and on HubSpot alike.

A step only reads the objects it lists, and only proposes the actions it lists:

- id: contacts
run: steps/contacts.ts
crm: { read: [companies, contacts], propose: [contacts.create, associate] }
Call Returns
companies.get(id) a company, or null
companies.search({ tax_id?, tax_root?, domain?, name?, limit? }) companies that match every field given
contacts.list({ company }) a company’s contacts
contacts.search({ email?, linkedin?, name? }) contacts that match
deals.list({ company, open?, limit? }) a company’s deals, only open ones with open: true
owners.get(id), owners.list() CRM users who own records, with name and email
notes.list({ company? , contact? }) notes on a company or a contact

Searches compare the way you’d expect: tax_id ignores punctuation, tax_root matches the start of the digits (a CNPJ’s 8-digit root finds every branch), domain ignores https:// and www., and name matches part of the name, ignoring case. limit defaults to 20, at most 100.

Records come back in Motions’ shape:

type CrmCompany = { id: string; name: string; domain: string | null; tax_id: string | null; owner_id: string | null; fields: Record<string, any> };
type CrmContact = { id: string; first_name: string | null; last_name: string | null; email: string | null; title: string | null;
linkedin: string | null; owner_id: string | null; company_ids: string[]; fields: Record<string, any> };
type CrmDeal = { id: string; name: string; stage: string | null; amount: number | null; is_closed: boolean; owner_id: string | null;
company_ids: string[]; fields: Record<string, any> };

fields holds the custom properties crm.yaml maps for that object, by their short names.

The company the step is running for is already in company.core and company.fields; read it again only if you need fields crm.yaml doesn’t map.

Writes return a reference to the proposed operation and change nothing yet. The changeset step after this one gathers them for an approver.

Call Proposes
companies.update(id, { fields }) new values for custom fields marked writable: true in crm.yaml
contacts.create(values) a contact; it needs an email or a linkedin URL
contacts.update(id, values) new values for a contact
associate(contact, company) linking a contact to a company
notes.create({ on, body }) a note on a company or a contact
notes.pin(note) pinning a note

Contact values are first_name, last_name, email, title, linkedin, and fields for custom properties marked writable: true.

Use a reference wherever an id goes, to build on a record that doesn’t exist yet:

const contact = await ctx.crm.contacts.create({ first_name: p.first_name, last_name: p.last_name, email: p.email, title: p.title });
await ctx.crm.associate(contact, company.core.crm.id);
await ctx.crm.notes.create({ on: contact, body: `Found by Motions: ${p.why}` });

When a proposal is made, Motions checks it against the CRM, refusing an update to a record that doesn’t exist or a field that isn’t writable, and records the current values so the approver sees before and after. contacts.create for an e-mail or LinkedIn URL that already exists is written as skipped.

Custom fields are read-only unless crm.yaml says otherwise:

companies:
fields:
icp_tier: { from: icp_tier, type: enum, writable: true }

The built-in test CRM, system: memory in crm.yaml, is a CRM Motions keeps itself. It’s where you try motions before HubSpot, and the only one that writes today. Load records into it with crm-seed.json, keyed by object, with each record’s properties under your CRM’s property names, and the lists batches open on:

{
"owners": [{ "id": "o1", "properties": { "name": "Ana Souza", "email": "ana@orbita.example" } }],
"companies": [{ "id": "201", "properties": { "name": "Metalúrgica Aurora", "domain": "aurorametais.example", "hubspot_owner_id": "o1" } }],
"contacts": [{ "id": "c1", "properties": { "firstname": "Rita", "email": "rita@aurorametais.example" }, "company_ids": ["201"] }],
"lists": { "first-batch": ["201"] }
}

The seed loads on the first push into an empty test CRM. npx motions push --reseed replaces everything in it.

runStep takes a crm option with records in Motions’ shape, and returns what the step proposed as proposals. See Test a motion.

Motions is built by Pruma. These docs describe what the platform does today; Limitations lists what it doesn't do yet. Open the app