Quickstart
In this quickstart you publish a small motion called Domain check to your tenant and run it over five made-up Brazilian companies. It looks up who registered each company’s domain, drops the companies it can’t check, asks you about the one whose domain belongs to someone else, and drafts a CRM note for each of the rest. You approve the notes, and Motions writes them.
Everything runs against the built-in test CRM, a CRM that Motions keeps itself, and against recorded source answers, so you need no HubSpot token and no provider keys.
Before you start
Section titled “Before you start”- An account in a tenant, as its owner or an admin. Pruma creates the tenant and sends you an invitation link; Join your tenant covers that part. You’ll need your tenant id (it’s in the app’s address, after
/t/) and your handle (Settings → Members). - Node.js 22 or later, and a terminal.
Run your first motion
Section titled “Run your first motion”-
Create an API token. In the app, open Settings → API tokens → New API token. Set Handle to your own handle and Role to admin, then create it. Copy the token right away: the app shows it once.
The command line tool sends this token with every request, and acts as that handle with that role.
-
Make a folder for your tenant, named after its id, with these files:
Directoryorbita/
- package.json
- tsconfig.json
- tenant.yaml
- crm.yaml
- crm-seed.json
Directoryfixtures/
Directorysources/
- rdap.json
Directorymotions/
Directorydomain-check/
- motion.yaml
- rules.yaml
Directorylib/
- names.ts
Directorysteps/
- domain.ts
- note.ts
Directorytests/
- domain.test.ts
The files are below. Change two things as you copy them: in
tenant.yaml, setidto your tenant id; inmotion.yaml, replaceanawith your handle in both places. -
Add the tenant files.
tenant.yamlnames the tenant and turns on fixture mode, so the built-in sources answer from recorded files instead of calling providers.tenant.yaml id: orbitatitle: Órbita Softwaretimezone: America/Sao_Paulo# Built-in sources answer from fixtures/sources/*.json, so nothing here needs a provider key.sources: { fixtures: true }crm.yamlconnects the built-in test CRM in read and write mode, and maps CRM property names to the names motions use.crm-seed.jsonholds the five companies and the list the batch opens on; Motions loads it on the first push.crm.yaml # The built-in test CRM: Motions keeps these records itself and loads crm-seed.json into it on the first push.# Property names are HubSpot's, so this field map carries over unchanged when you connect HubSpot.system: memorymode: read_writecompanies:core: { tax_id: cnpj, owner_id: hubspot_owner_id }fields:segment: { from: segmento, type: enum }employees: { from: numberofemployees, type: number }contacts:core: { first_name: firstname, last_name: lastname, title: jobtitle, linkedin: hs_linkedin_url, owner_id: hubspot_owner_id }deals:core: { name: dealname, stage: dealstage, is_closed: hs_is_closed, owner_id: hubspot_owner_id }notes:core: { body: hs_note_body }crm-seed.json {"owners": [{ "id": "o1", "properties": { "name": "Ana Souza", "email": "ana@orbita.example" } }],"companies": [{"id": "201","properties": {"name": "Metalúrgica Aurora","domain": "aurorametais.example","segmento": "industria","numberofemployees": 420,"hubspot_owner_id": "o1","country": "BR"}},{"id": "202","properties": {"name": "Laticínios Serra Verde","domain": "www.serraverde.example","segmento": "alimentos","numberofemployees": 180,"hubspot_owner_id": "o1","country": "BR"}},{"id": "203","properties": {"name": "Pátio Logística","domain": "patiolog.example","segmento": "logistica","numberofemployees": 95,"hubspot_owner_id": "o1","country": "BR"}},{"id": "204","properties": { "name": "Café Bandeirante", "segmento": "alimentos", "numberofemployees": 30, "hubspot_owner_id": "o1", "country": "BR" }},{"id": "205","properties": {"name": "Nuvem Clara Tecnologia","domain": "nuvemclara.example","segmento": "software","numberofemployees": 60,"hubspot_owner_id": "o1","country": "BR"}}],"lists": { "first-batch": ["201", "202", "203", "204", "205"] }}rdap.jsonholds the answers the domain registry gives for these companies. One of them,nuvemclara.example, has no record.fixtures/sources/rdap.json [{"op": "rdap.lookup","request": { "domain": "aurorametais.example" },"data": {"domain": "aurorametais.example","registrant": "METALURGICA AURORA LTDA","registeredAt": "2009-03-17T00:00:00Z","changedAt": "2025-03-17T00:00:00Z","status": ["active"],"server": "rdap.example"}},{"op": "rdap.lookup","request": { "domain": "serraverde.example" },"data": {"domain": "serraverde.example","registrant": "LATICINIOS SERRA VERDE S.A.","registeredAt": "2011-06-02T00:00:00Z","changedAt": "2025-06-02T00:00:00Z","status": ["active"],"server": "rdap.example"}},{"op": "rdap.lookup","request": { "domain": "patiolog.example" },"data": {"domain": "patiolog.example","registrant": "GRUPO TRANSVIA PARTICIPACOES LTDA","registeredAt": "2018-09-20T00:00:00Z","changedAt": "2026-01-10T00:00:00Z","status": ["active"],"server": "rdap.example"}},{ "op": "rdap.lookup", "request": { "domain": "nuvemclara.example" }, "data": null }] -
Add the motion.
motion.yamllists three steps: two that run code for each company, and a changeset that collects the notes for approval. It also says who answers thedomain_ownerquestion, how long they have, and what happens if nobody answers.motions/domain-check/motion.yaml title: Domain checkapprovers: [ana]reasons: [no_domain, unregistered]input: { list: first-batch }steps:- id: domaintitle: Look up the domainrun: steps/domain.tssources: [rdap.lookup]- id: notetitle: Draft a CRM noterun: steps/note.tscrm: { propose: [notes.create] }- id: writekind: changesettitle: Write the notesapproval: requiredqueues:domain_owner: { title: Who owns this domain?, owner: ana, deadline: 1d, default: hold }The first step looks the domain up. It drops a company with no domain or an unregistered one, and asks a person when the registrant’s name doesn’t look like the company’s.
motions/domain-check/steps/domain.ts import { defineStep, drop } from "@motionskit/sdk";import { sameCompany } from "../lib/names.ts";export default defineStep({async run(company, ctx) {const domain = company.core.domain?.replace(/^www\./, "");if (!domain) return drop("no_domain");const rdap = await ctx.sources.rdap.lookup({ domain });if (!rdap) return drop("unregistered");if (rdap.registrant && !sameCompany(company.core.name, rdap.registrant)) {await ctx.ask({type: "domain_owner",prompt: `${domain} is registered to ${rdap.registrant}, not ${company.core.name}. Is it the same company?`,options: [{ id: "same", text: "Same company: keep going" },{ id: "other", text: "Someone else's domain: drop it from this batch", effect: "drop" },],recommended: "same",evidence: [{ source: "rdap.lookup", shows: `registrant: ${rdap.registrant}` }],});}const since = rdap.registeredAt ? Number(rdap.registeredAt.slice(0, 4)) : null;const years = since === null ? null : ctx.now().getUTCFullYear() - since;console.log(`${domain} is registered to ${rdap.registrant ?? "an undisclosed owner"} since ${since ?? "an unknown year"}`);return { facts: { domain, registrant: rdap.registrant, since, years } };},});motions/domain-check/lib/names.ts const FILLER = new Set(["ltda", "eireli", "grupo", "participacoes", "comercio", "industria"]);/** The words that identify a company: accents, case, punctuation and legal suffixes removed. */function words(name: string): string[] {return name.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2 && !FILLER.has(w));}/** True when the registrant's name shares a word with the company's, so "METALURGICA AURORA LTDA" matches "Metalúrgica Aurora". */export function sameCompany(name: string, registrant: string): boolean {const theirs = new Set(words(registrant));return words(name).some((w) => theirs.has(w));}The second step drafts a note.
ctx.crm.notes.createdoesn’t write anything: it proposes the note for the changeset. It readsyoung_yearsfromrules.yaml.motions/domain-check/steps/note.ts import { defineStep } from "@motionskit/sdk";type Domain = { domain: string; registrant: string | null; since: number | null; years: number | null };export default defineStep({async run(company, ctx) {const found = company.facts.domain as Domain;const young = Number(ctx.rules.young_years);const age = found.years === null ? "of unknown age" : found.years < young ? `young (${found.years} years)` : `${found.years} years old`;const body = `Domain check: ${found.domain} is registered to ${found.registrant ?? "an undisclosed owner"} and is ${age}.`;// Nothing is written yet: this lands in the "write" changeset, and an approver decides.await ctx.crm.notes.create({ on: company.core.crm.id, body });return { facts: { body } };},});motions/domain-check/rules.yaml # Values the steps read as ctx.rules. Editing this file makes a new version, like editing a step.young_years: 3 -
Add a test for the first step. It runs the step against the recorded answers, without the platform.
motions/domain-check/tests/domain.test.ts import { runStep } from "@motionskit/sdk/testing";import { expect, test } from "vitest";import domain from "../steps/domain.ts";// The tenant's recorded RDAP answers. Inside the tenant folder runStep finds them on its own; naming the folder lets the test run from anywhere.const fixtures = new URL("../../../fixtures/sources/", import.meta.url).pathname;const now = "2026-11-06T12:00:00Z";test("drops a company with no domain, without calling RDAP", async () => {const out = await runStep(domain, { company: { core: { name: "Café Bandeirante", domain: null } }, fixtures, now });expect(out).toMatchObject({ status: "ok", outcome: { lane: "drop", reason: "no_domain" } });expect(out.calls).toEqual([]);});test("records who registered the domain, and since when", async () => {const out = await runStep(domain, { company: { core: { name: "Metalúrgica Aurora", domain: "aurorametais.example" } }, fixtures, now });expect(out).toMatchObject({status: "ok",facts: { domain: "aurorametais.example", registrant: "METALURGICA AURORA LTDA", since: 2009, years: 17 },});});test("strips www before the lookup", async () => {const out = await runStep(domain, { company: { core: { name: "Laticínios Serra Verde", domain: "www.serraverde.example" } }, fixtures, now });expect(out.calls.map((c) => [c.op, c.request])).toEqual([["rdap.lookup", { domain: "serraverde.example" }]]);});test("drops a domain nobody registered", async () => {const out = await runStep(domain, { company: { core: { name: "Nuvem Clara Tecnologia", domain: "nuvemclara.example" } }, fixtures, now });expect(out).toMatchObject({ status: "ok", outcome: { lane: "drop", reason: "unregistered" } });});test("asks when someone else registered the domain", async () => {const out = await runStep(domain, { company: { core: { name: "Pátio Logística", domain: "patiolog.example" } }, fixtures, now });expect(out).toMatchObject({ status: "asked", question: { type: "domain_owner", recommended: "same" } });});test("keeps going once someone answers that it is the same company", async () => {const out = await runStep(domain, {company: { core: { name: "Pátio Logística", domain: "patiolog.example" }, answers: { domain_owner: "same" } },fixtures,now,});expect(out).toMatchObject({ status: "ok", facts: { registrant: "GRUPO TRANSVIA PARTICIPACOES LTDA", since: 2018 } });}); -
Add
package.jsonandtsconfig.json, then install.package.json {"name": "orbita","private": true,"type": "module","scripts": {"check": "motions check","test": "motions test","typecheck": "tsc -p .","push": "motions push"},"devDependencies": {"@motionskit/cli": "^0.1.0","@motionskit/sdk": "^0.1.0","typescript": "^5.9.3","vitest": "^5.1.0","zod": "^4.6.7"}}tsconfig.json {"compilerOptions": {"target": "ES2022","module": "ESNext","moduleResolution": "Bundler","allowImportingTsExtensions": true,"strict": true,"noEmit": true,"skipLibCheck": true},"include": ["motions", "sources", ".motions/*.d.ts"]}Terminal window npm install -
Check and test it.
motions checkcompiles every motion the way the platform will, and reports any mistake in the YAML with its path.motions testruns the tests.Terminal window npx motions checknpx motions testnpx motions check orbita (America/Sao_Paulo)domain-check @ 4c427355d5 3 steps, 2 bundles (29 KB), rules-4a23393c5ccompany domain d8e4f9e6 sources=[rdap.lookup]company note 6e77fd81 sources=[]changeset writefixtures on: built-in sources answer from 4 entries in 1 file -
Push it live. Point the command line tool at the app and give it your token, then push.
MOTIONS_URLis the address you open the app at, without any path.--livemakes this version the one new batches use.Terminal window export MOTIONS_URL=https://motions.pruma.techexport MOTIONS_TOKEN= # paste your API token after the =npx motions push --livenpx motions push --live seeded orbita's CRM with 6 recordsorbita is in fixture mode: built-in sources answer from 4 fixturesorbita/domain-check @ 4c427355d5 (new, live)The last line is followed by a link to the version in the app.
-
Open a batch. In the app, go to Batches → Open batch, pick Domain check, choose the CRM list first-batch, and open it. Or, from the terminal, replacing
<tenant>:Terminal window npx motions open <tenant> domain-check --list first-batch -
Watch it run. Within seconds the batch screen shows each company as a row of bricks, one per step:
Company What happens Metalúrgica Aurora The registrant matches. A note is drafted, and the company waits at Write the notes. Laticínios Serra Verde Same, after www.is stripped from its domain.Pátio Logística The domain is registered to another group, so the step asks you. Waiting. Café Bandeirante No domain in the CRM. Dropped, no_domain.Nuvem Clara Tecnologia The registry has no record. Dropped, unregistered.The changeset doesn’t seal yet: Pátio Logística might still reach it, so Motions waits for your answer.
-
Answer the question. Open Questions (or the batch’s questions panel). Under Who owns this domain? you’ll find Pátio Logística, the evidence from the registry, and the recommended answer. Choose Same company: keep going.
Pátio Logística runs again from the top of the step, now with your answer, and drafts its note. With nobody left upstream, Motions seals the changeset: three notes, waiting for approval.
-
Approve the changeset. On the batch screen, open the changeset under Write the notes. Each operation shows the company and the note’s text. Choose Approve, then Approve and write.
The built-in test CRM applies the three notes, each operation gets a receipt marked written, and the three companies are Done.
What you just did
Section titled “What you just did”- You published a version of a motion from a folder. The batch you opened keeps that version, whatever you push next.
- Steps returned facts, dropped companies with a reason from the motion’s
reasonslist, and asked a closed question. - The question went to its queue’s owner, you. Had you not answered within a day, the queue’s default,
hold, would have kept Pátio Logística out of this batch so the others could go ahead. - The step proposed CRM notes; nothing was written until an approver approved the changeset.
- Change
young_yearsinrules.yaml, runnpx motions push, and find the new draft under Motions → Domain check → Versions. Make it live there, then open another batch. - Connect your real CRM: Set up a tenant walks through HubSpot, provider keys, your team and GitHub.
- Write your own steps: start at How a motion is built.
Motions is built by Pruma. These docs describe what the platform does today; Limitations lists what it doesn't do yet. Open the app