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.
- Works with
- 40 calls, and
Goal.pursue - 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.
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.
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.
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, | The sentence matches one guide’s says | / 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
/** 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.
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 sawA finished goal returns the result and why it stopped
/** 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
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:. 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.
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
}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/ | 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: 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= or --send to run it.
// 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' })# 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"}}' { "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' } })" } }