Skip to content

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.

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 } };
},
});

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

Return one of these, or nothing:

return { facts: { cnpj: match.cnpj, legal_name: match.razao_social } }; // carry on, with facts
return drop("not_icp"); // out of the batch
return hold("open_deal"); // set aside, out of the batch
return { ...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.
  • questions adds non-blocking questions, and events emits events for checkpoints.

If the motion lists reasons, the reason must be one of them.

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 } };
},
});
  • 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.log and friends are recorded with the run, up to 200 lines, and shown in the drawer.
  • No network. A step can’t call fetch. Everything outside the CRM comes through sources.
  • Frozen time and seeded randomness. new Date() and Date.now() return the moment the run was dispatched, the same as ctx.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_process and 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.

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