Compose your own
Write the steps once and call them by one name: your API, CLI, SDK and MCP get the call, priced before it runs.
- Writes
workflow()andService()- Deploy computes
- The kind and the price
- Tests
- Free, against test fixtures
- SDK
startup-builder@0.3
One file becomes one call
verb publishes the file as a Verb on a hub Noun in your own namespace. input becomes its schema, maxCost its cap, and trigger when it runs on its own. It may not shadow a platform Verb: Audience.build is taken, so this file names its call Audience.buildHiring.
// workflows/hiring-brokers.ts: Leaseline's own weekly list of property managers who just hired
import { $, workflow } from 'startup-builder'
export default workflow({
name: 'hiring-brokers',
verb: 'Audience.buildHiring', // a new Verb on a hub Noun, in your namespace
input: { roles: 'string[]', size: 'integer' },
trigger: { schedule: 'mon 07:00' },
maxCost: 3,
execute: async (ctx, { roles = ['leasing coordinator'], size = 50 }) => {
const hiring = await ctx.step('hiring', () => $.Organization.find({ icp: 'icps/icp_4' , where: { hiringFor: roles }, limit: size * 2, maxCost: 0.1 }))
return ctx.step('list', () => $.Audience.build({ icp: 'icps/icp_4' , titles: ['owner'], size, from: hiring.organizations, maxCost: 2.5 }))
},
})npx startup-builder dev # previews the new Verb as a Change, with its quote ctx method | What it does |
|---|---|
ctx.step(name, | Runs one call exactly once, keyed by name; a retry replays its result and never charges twice |
ctx.sleep(duration) | Waits, free: ctx.sleep('1d') |
ctx.waitForEvent(name, | Waits for an Event, such as a reply, free |
ctx.ask(question) | Puts one question to a person, before money moves |
ctx.approve(what) | Waits on an Approval: only where you set an approval rule, since send and publish need none by default |
Deploy computes the kind and the price
The kind is the promise, read from two fields, never from whether a step is code or agentic. The price is the sum of each step’s maxCost, capped by the file’s own, so try quotes it before it runs.
| The file has | Deploy makes it | Deploy refuses with |
|---|---|---|
Neither grade nor contract | A Workflow: the tool works | / when a step has no maxCost |
grade: | An agent task: each output passes its grade, or is redone or free | / |
contract: | A Service: the outcome, warranted | / when review or oversight is missing, or contract.done names no Event |
Both grade and contract | Refused: a Service warrants its outcome under its contract, so its grade is its review | / |
Add a grade and it becomes an agent task
A grade is an Evaluator’s verdict on each output before it counts, and what happens on a fail: redo or free. The Evaluator runs on another model than the step it grades. This file answers one problem post as you, and follows the reply.
// workflows/answer-the-asker.ts: one problem post in, one graded answer posted as you, and the reply followed
import { $, workflow } from 'startup-builder'
export default workflow({
name: 'answer-the-asker',
verb: 'Post.answerAsker',
input: { thread: 'string' },
trigger: { event: 'Keyword.searched', where: "change = 'problem-question'" }, // a Monitor on Keyword.search
maxCost: 0.3,
grade: {
evaluator: 'a second model reads the answer beside the thread, its best answers and the Offer' ,
passWhen: 'it helps before it mentions anything; every claim holds; it reads like the best answers' ,
remedy: 'redo',
},
execute: async (ctx, { thread }) => {
const draft = await ctx.step('draft', () => $.Content.draft({ kind: 'reply', thread, from: 'offer', mention: 'when-asked', maxCost: 0.02 }))
const reply = await ctx.step('post', () => $.Post.publish({ replyTo: thread, body: draft.body, maxCost: 0.25 })) // graded before it posts
const answer = await ctx.waitForEvent('Post.replied', { where: `thread = '${reply.thread}'`, timeout: '7d' })
if (!answer) return { reply, outcome: 'no reply in 7 days' }
return ctx.step('engage', () => $.Prospect.engage({ from: answer.$id })) // a Lead, keeping this thread as its source
},
})Add a contract and it becomes a Service
A contract warrants the outcome: the Event that means done, who accepts it and how, the assurance, the remedy and the Event it bills on. A Service also names its review patterns and its oversight posture; a posture above none names its reason, and a capability gap is never one.
// workflows/comparison-pages.ts: a page per rival, published and indexed, warranted
import { $, Service } from 'startup-builder'
export default Service({
name: 'comparison-pages',
verb: 'Page.answerComparison',
input: { rivals: 'string[]', site: 'string' },
maxCost: 1,
review: [
{ pattern: 'adversarial', rubric: 'every claim about a rival cites its own page or a public review' },
{ pattern: 'affirmative', rubric: 'a fair table first; who each product fits best' },
],
oversight: { posture: 'none' },
contract: {
done: 'Content.published', // the Event that means done
accept: { by: 'site-owner', within: '7d', silence: 'accept' },
assurance: 'graded',
remedy: 'redo',
earns: 'Content.published',
},
execute: async (ctx, { rivals, site }) => {
for (const rival of rivals) {
await ctx.step(`page: ${rival}`, () => $.Page.answer({ question: `Leaseline vs ${rival}`, site, maxCost: 0.3 }))
}
},
})Extend a platform call with base()
To change a platform call, extend it. Name the steps you replace, and base() runs the rest unchanged, so a later fix to the platform call reaches yours.
import { $, workflow } from 'startup-builder'
export default workflow({
name: 'audience-build-units',
verb: 'Audience.buildByUnits',
extends: 'Audience.build',
maxCost: 3,
steps: {
// replace one named step; base() runs every other step of Audience.build unchanged
companies: (ctx, input) => ctx.step('companies', () => $.Organization.find({ ...input, question: 'How many rental units do you manage?' , maxCost: 0.8 })),
},
execute: async (ctx, input, base) => base(input),
})Every call you write takes a goal
Deploy gives each call the ['code', cascade: with every input set, the code runs; with { goal,, the agent plans from your steps. Deploy asks for three to fifteen phrasings, its says, so a founder’s sentence finds it (goal mode).
| Field | What deploy does with it |
|---|---|
says | The phrasings an agent matches a sentence against; without them, the call takes goals only through Goal.pursue |
tools | Your step Verbs, which the agent may call more or fewer times than the file does |
maxCost | The most one call spends; the goal’s budget sits above it |
Test it for $0 against test fixtures
The SDK’s run runs the file with --env test: each data step returns its captured fixture, and an agentic step runs on recorded model output, so a test costs nothing and answers the same way each time.
import { run } from 'startup-builder'
import hiring from '../workflows/hiring-brokers'
const out = await run(hiring, { roles: ['leasing coordinator'], size: 10 }, { env: 'test' })
expect(out.cost.usd).toBe(0) // test fixtures answer every step for $0
expect(out.audience.size).toBe(10)Start from a receipt, or let your agent propose it
A goal whose every change of course can be written as a rule offers “Make this a Workflow”: the plan’s steps become ctx.step calls and each Decision a declared rule. An agent that makes the same calls three times proposes the call itself: MCP do with define returns the draft and its quote, and deploying it waits for you unless a standing approval covers it.
{ "name": "do", "arguments": {
"mode": "try",
"define": { "verb": "Audience.buildHiring",
"steps": [ { "call": "Organization.find", "input": { "where": { "hiringFor": ["leasing coordinator"] } } },
{ "call": "Audience.build", "input": { "titles": ["owner"], "size": 50 } } ] } } }