Write a step
A step is a TypeScript file that exports a function wrapped in defineStep. Motions calls it once per company, with what’s known about the company so far, and records what it returns.
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 } }; },});What the step gets
Section titled “What the step gets”run(company, ctx) gets two arguments.
company is everything known about this company in this batch:
| Field | Holds |
|---|---|
company.core |
name, domain, country, tax_id, and crm: { system, id, owner_id }, the record’s id in your CRM |
company.fields |
the CRM’s custom fields that crm.yaml maps, by their short names: company.fields.segment |
company.facts |
what earlier steps returned, by step id: company.facts.identity.cnpj |
company.answers |
answers to this step’s questions, by question type |
company.key |
the company’s key in the batch |
ctx is how the step does anything beyond looking at the company:
| Member | Does |
|---|---|
ctx.sources |
calls sources: ctx.sources.rdap.lookup({ domain }). See Use sources. |
ctx.crm |
reads the CRM and proposes changes. See Read and propose CRM changes. |
ctx.rules |
the values in rules.yaml |
ctx.ask(question) |
asks a person and waits for the answer. See Ask questions. |
ctx.answer(type) |
reads an answer to one of this step’s questions, without asking |
ctx.wait(every, reason) |
stops, and runs the step again after every. See Wait for time and events. |
ctx.now() |
when this run was dispatched |
ctx.calendar |
the motion’s calendar, resolved to instants when the batch opened |
ctx.batch |
the batch’s id, label, motion and openedAt |
What the step returns
Section titled “What the step returns”Return one of these, or nothing:
return { facts: { cnpj: match.cnpj, legal_name: match.razao_social } }; // carry on, with factsreturn drop("not_icp"); // out of the batchreturn hold("open_deal"); // set aside, out of the batchreturn { ...drop("duplicate"), facts: { twin_of: other.key } }; // out, with facts kept for the record- Facts are any JSON. Later steps read them as
company.facts.<this step's id>, and people see them in the company’s drawer. drop(reason)moves the company to the dropped lane: it’s not a fit, or it’s a duplicate.hold(reason)moves it to held: it’s a fit, but not for this batch. Both take it out of the batch for good. Import them from the SDK.{ outcome: { lane: "done", reason } }finishes the company early, skipping the remaining steps.questionsadds non-blocking questions, andeventsemits events for checkpoints.
If the motion lists reasons, the reason must be one of them.
Typed facts
Section titled “Typed facts”Give defineStep an output schema, with zod or any Standard Schema library, and the facts are typed and checked. Facts that don’t match fail the step, with the mismatch in the error.
import { defineStep } from "@motionskit/sdk";import * as z from "zod";
export default defineStep({ output: z.object({ cnpj: z.string(), legal_name: z.string(), confidence: z.number() }), async run(company, ctx) { // ... return { facts: { cnpj, legal_name, confidence } }; },});When something goes wrong
Section titled “When something goes wrong”- A source call that fails for a passing reason is retried by Motions; the step runs again from the top. See Use sources.
- An error you throw, or an exception in your code, fails the step for this company. The cell turns failed, the company is parked, and the error and your logs are in the company’s drawer. Someone retries it after fixing the cause.
console.logand friends are recorded with the run, up to 200 lines, and shown in the drawer.
The rules a step runs under
Section titled “The rules a step runs under”- No network. A step can’t call
fetch. Everything outside the CRM comes through sources. - Frozen time and seeded randomness.
new Date()andDate.now()return the moment the run was dispatched, the same asctx.now().Math.random()is seeded from the run, so it gives the same numbers on a re-run. - Runs again from the top. After an answer, a retry or a wait, the whole function runs again. Source answers come from the cache and CRM reads from the batch’s snapshot, so this is cheap, but don’t count on variables surviving between runs.
- No Node built-ins. Steps run in a sandbox, not Node. Plain TypeScript and npm packages that don’t need Node work;
fs,child_processand the like don’t. - Limits. 30 seconds of CPU per run, 500 source and CRM calls, and 256 KiB of facts and proposals per company.
Imports
Section titled “Imports”A step can import other files in the motion folder, such as ../lib/names.ts, and npm packages from the tenant’s package.json. motions check bundles each step with everything it imports into one file, which is part of the version.
Import the SDK as @motionskit/sdk: defineStep, defineBatchStep, drop, hold, soft, defineSource, SourceError, and the types. Test helpers are in @motionskit/sdk/testing.
Motions is built by Pruma. These docs describe what the platform does today; Limitations lists what it doesn't do yet. Open the app