URL: /code/compose · Id: `page:/code/compose`

# 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.

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()` and `Service()`
- **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.

```ts
// 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 }))
  },
})
```

```bash
npx startup-builder dev                         # previews the new Verb as a Change, with its quote
```

| `ctx` method | What it does |
| --- | --- |
| `ctx.step(name, fn)` | 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, { where, timeout })` | 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 | `/errors/uncapped-step` when a step has no `maxCost` |
| `grade: { evaluator, passWhen, remedy }` | An agent task: each output passes its grade, or is redone or free | `/errors/uncapped-step` |
| `contract: { done, accept, assurance, remedy, earns }` | A Service: the outcome, warranted | `/errors/service-needs-contract` 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` | `/errors/service-needs-contract` |

## 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.

```ts
// 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.

```ts
// 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.

```ts
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', 'agentic']` cascade: with every input set, the code runs; with `{ goal, budget }`, the agent plans from your steps. Deploy asks for three to fifteen phrasings, its `says`, so a founder’s sentence finds it ([goal mode](/code/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.

```ts
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.

```json
{ "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 } } ] } } }
```