URL: /code/goal-mode · Id: `page:/code/goal-mode`

# Goal mode

> Give any Workflow, agent task or Service a goal and a budget: try returns the priced plan free, and send runs it with a receipt.

Give any [Workflow](https://startupbuilder.dev/business/nouns/workflow), agent task or [Service](https://startupbuilder.dev/business/nouns/service) a goal and a budget: try returns the priced plan free, and send runs it with a receipt.

- **Works with:** [40 calls, and `Goal.pursue`](/capabilities/how-calls-compose#catalog)
- **Dry run:** Free
- **SDK:** `startup-builder@0.3`

The overview, in a founder's words: [Give it a goal: the plan, the budget, the receipt](/capabilities/goal-mode).

## Two inputs turn any call into a goal
`goal` and `budget` sit beside a call's own inputs. Each typed input you also give is a hard rule the agent never relaxes. Ad money is `spend`, never `budget`, so the budget means one thing: the Limit on the work.

```ts
type Money = { amount: number; currency: 'USD' }

/** Every Workflow, agent task and Service accepts these in place of, or beside, its typed inputs. */
type GoalModeInput = {
  goal: string                        // the outcome, in your words
  budget: Money                       // the Limit on this Action: calls, model tokens, browser steps
  spend?: Money                       // money paid to a Venue (ad spend, a paid listing), under your Mandate; default 0
  by?: string                         // the deadline, ISO 8601; default now + 7 days; at most 31 days
  ask?: 0 | 1                         // questions it may ask before money moves; default 1
  plan?: GoalPlan                     // start from this plan (what a dry run returned) instead of planning again
  // ...plus any of the call's own typed inputs. Each one you give is a hard rule.
}

type GoalPursueInput = Omit<GoalModeInput, 'goal'> & {
  objective: string                   // the hub Goal's objective
  inputs?: Record<string, unknown>    // records you already hold: { offer, icp, audience, mailbox }
  use?: string[]                      // Verbs it may call; default every composition the seat may call
}
```

## Two doors: one call with a goal, or one goal across calls
Most sentences match one composition's guide, so the goal goes to that call. An outcome that needs several, such as booking calls, goes to `Goal.pursue`, which creates the hub Goal and pursues one Key Result inside one Action. Its Event carries `met`.

```ts
import { $ } from 'startup-builder'

// One call, with a goal: most asks match one composition's guide
const plan = await $.try.Audience.build({
  goal: '50 verified heads of operations at US property managers with 100 to 1,000 units',
  budget: { amount: 4, currency: 'USD' },
})

// An outcome that spans calls: Goal.pursue composes them inside one Action
const booked = await $.try.Goal.pursue({
  objective: 'Book 5 calls with property managers this month from cold email. Send only from the warmed domain.',
  budget: { amount: 40, currency: 'USD' },
  by: '2026-11-26',
  use: ['Audience.build', 'Prospect.reach', 'Lead.respond', 'Meeting.book', 'Deal.brief'],
})
```

| Door | When | Refuses |
| --- | --- | --- |
| Any composition with `{ goal, budget }` | The sentence matches one guide’s `says` | `/errors/no-plan` when no plan fits the inputs |
| `Goal.pursue` | The outcome spans compositions, or matches several guides | A horizon past 31 days, with the fix: `PlayMix.compile` chooses Plays week by week |

## How a sentence finds its call
| Step | What happens |
| --- | --- |
| 1. Match | Your sentence is matched against every guide’s `says`, and its `not` list drops look-alikes. One match goes to that call; several, or none, go to `Goal.pursue` with those calls in `use`. |
| 2. Read | Each phrase becomes a row of `reads`. Your words are `hard`; a filter the agent adds, such as a headcount standing in for units, is soft and may widen. |
| 3. Plan | It starts from the guide’s default program, sizes each step with free counts, prices it at its unit price, and adds one contingency step for the likeliest shortfall. |
| 4. Ask, at most once | Only when a reading would change the price by more than twice or the result by more than half: “Verified: do catch-all addresses count?” |

## The plan is what try returns
```ts
/** What `try` returns: the dry run. Nothing is stored, and nothing is spent but $0 reads. */
type GoalPlan = {
  goal: { objective: string; keyResult: { metric: string; target: number; by: string } }
  reads: { said: string; means: string; hard: boolean }[]   // hard: from your words, never relaxed
  steps: { n: number; operation: string; count: number; unit: string; unitUsd: number; usd: number
           why: string; if?: string }[]                      // if: a contingency step, priced, not in expected
  estimate: { usd: { expected: number; max: number }; minutes: number; fill: string }
  question?: { text: string; choices?: string[] }            // at most one
  budget: Money & { left: number }
}
```

To run that exact plan, pass it back as `plan`. The agent starts from it, and any change of course is still a Decision on the receipt.

```ts
const draft = await $.try.Audience.build({ goal, budget })                         // the plan and its estimate, $0
const action = await $.send.Audience.build({ goal, budget, plan: draft })          // runs the plan you saw
```

## A finished goal returns the result and why it stopped
```ts
/** What a finished goal returns, beside the call's own output. */
type GoalResult<T> = {
  result: T                           // AudienceBuilt, KeywordMap, ...
  goal: { met: boolean; reached: number; target: number
          stoppedBecause: 'met' | 'budget' | 'deadline' | 'no-more-supply' | 'refused' }
  receipt: Receipt
}
```

| `stoppedBecause` | Means |
| --- | --- |
| `met` | It reached the target and stopped, with budget left |
| `budget` | The next call did not fit, and the Approval with its projection was refused or expired; or it reached `maxSteps` |
| `deadline` | It reached `by` |
| `no-more-supply` | No route had more at any price, such as every matching company already read |
| `refused` | A call it needed was refused, such as an account that needs signing in again |

## The receipt is a view over one Action
```ts
type Receipt = {
  calls: { n: number; operation: string; action: string; status: 'done' | 'failed' | 'canceled' | 'refused'
           count: number; usd: number; why?: string }[]     // MCP do's calls[], plus count, usd and why
  decisions: { $id: string; at: string; options: string[]; choice: string; reason: string; supersedes?: string }[]
  approvals: { $id: string; asked: string; status: 'approved' | 'refused' | 'expired' }[]
  budget: { amount: number; spent: number; left: number; agentUsd: number }   // agentUsd: the planner's own model cost
  spend?: { amount: number; spent: number; venue: string }
  time: { startedAt: string; endedAt?: string; activeMinutes: number }
}
```

Its totals equal the sum of `calls[].usd`, and `budget.left` is the budget less that sum. The planner's own model cost is `agentUsd`, on its own line, usually 2¢ to 10¢ a goal. The receipt is never a File: it is the Action, its child calls, their Cost entries and its Decisions.

## Progress streams as Events on the goal’s Action
Every frame carries `budget: { spent, left }`. Filter on `on`, the direct parent; grandchildren, such as the routes inside a find, arrive inside their parent's Event. Over MCP, pass a `progressToken` on the `do` call for one progress notification per frame, or subscribe to the same events URL as a resource.

```ts
const action = await $.send.Audience.build({ goal, budget })
for await (const e of $.stream('*', { where: `on = ${action.$id}` })) {
  console.log(e.name, e.data.budget?.spent)        // Decision.created 0 · Prospect.verified 0.0005 · ...
  if (e.name === 'Audience.built') break
}
```

```bash
curl -N "https://api.sb/leaseline.app/events?where=on%20%3D%20act_7Kq2" \
  -H "Authorization: Bearer $SB_TOKEN" \
  -H "Accept: text/event-stream"
```

| Event | When |
| --- | --- |
| `Decision.created` | The plan is fixed; the plan is its `data` |
| Each child’s Event, such as `Prospect.verified` | Each call finishes, with `count` and `usd` |
| `Decision.superseded` | It changes course, with the options and the reason |
| `Approval.created` | It asks its one question, or the next call would cross the budget |
| The call’s own Event, such as `Audience.built` | It ends, with the receipt |

## Failure, retries and steering keep every call once
| Case | What happens |
| --- | --- |
| Idempotency | The goal’s Action takes your `Idempotency-Key`; each child call takes `<key>-<n>`. A retry after a timeout replays finished calls and never charges twice. |
| A child call fails | Three retries with backoff, then the next route for the same read, which no Decision records. Then it is an input to the next Decision: switch calls, skip the items, or stop. |
| `Action.cancel` | Stops the goal. Calls made stay on the receipt and are charged; pending sends and posts are canceled at $0. |
| `Action.redirect` | Replaces inputs, such as a new `by` or a narrower goal; the agent plans again as a superseding Decision. |
| `/errors/no-plan` | The planner returned no valid plan. No money moves before a plan exists. The fix: call it with its typed inputs. |
| `maxSteps` | Default 200 child calls. A goal that reaches it stops with `stoppedBecause: budget` and the reason “step limit”: a guard against loops, not cost. |

## The four forms, side by side
The SDK, the API, the CLI and MCP take the same goal. Each shows the dry run; send the same body with `mode=send` or `--send` to run it.

```ts
// SDK: npm i startup-builder@0.3 · SB_TOKEN=sk_test_... reaches the test environment for $0
import { $ } from 'startup-builder'
const goal = '50 verified heads of operations at US property managers with 100 to 1,000 units'
const budget = { amount: 4, currency: 'USD' }
const plan = await $.try.Audience.build({ goal, budget })
const done = await $.do.Audience.build({ goal, budget, plan }, { idempotencyKey: 'leaseline-audience-build-1' })
```

```bash
# API
curl -X POST "https://api.sb/leaseline.app/audiences/build?mode=try" \
  -H "Authorization: Bearer $SB_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"goal":"50 verified heads of operations at US property managers with 100 to 1,000 units","budget":{"amount":4,"currency":"USD"}}'

# CLI: --do is the default for writes, so a goal names its Mode
npx startup-builder do Audience.build --try \
  --data '{"goal":"50 verified heads of operations at US property managers with 100 to 1,000 units","budget":{"amount":4,"currency":"USD"}}'
```

```json
{ "name": "do", "arguments": {
    "mode": "try",
    "code": "return await $.Audience.build({ goal: '50 verified heads of operations at US property managers with 100 to 1,000 units', budget: { amount: 4, currency: 'USD' } })" } }
```