Cost controls

Stop an agent before it overspends: cap halts before the call, budget pauses a rolling window, the cache replays identical calls free, fallback survives an outage.

AI spend has three shapes of failure: one run runs away, a whole workflow runs away, or a burst saturates your provider quota. Duraton gives each its own control, declared on defineWorkflow next to retry, plus two per-call options that cut spend and absorb provider failures.

Rolling AI budgets in the console
defineWorkflow({
  name: "summarize",
  cap: { maxCost: 0.25 },                                  // ceiling on ONE run's spend -> fail
  budget: { maxCost: 5, windowMs: 3_600_000 },             // ceiling on the workflow's spend -> pause
  tokenThrottle: { tokens: 100_000, perMs: 60_000 },       // token rate -> delay the start
  handler,
});
ControlScopeActsWhen it fires
capone runbefore a step.ai callThe run fails with a BudgetError.
budgetthe workflow, over a rolling windowon Duraton's budget checkIn-flight runs pause, then auto-resume.
tokenThrottleruns sharing a keyat run start, debited after each AI stepThe next runs start later.
cacheone generate callbefore the provider callAn identical prior call is served with zero spend.
fallbackone generate callafter a retryable failureThe call advances to the next model.

The reference tables for the three workflow fields live in flow control; the per-call options are in step.ai.generate. This page is how to choose between them.

cap - bound one run

cap is a hard ceiling on a single run's AI spend. The run halts before the step.ai call that would cross a set axis - that call never runs - and fails with a BudgetError. Everything committed before the halt stays committed.

defineWorkflow({
  name: "research.agent",
  cap: { maxCost: 0.25, maxTokens: 40_000 },
  handler: async (ctx) => {
    await ctx.step.ai.loop("agent", {
      prompt: ctx.event.data.brief,
      maxIterations: 20,
      tools: { search: { handler: (q) => search(q) } },
      turn: (c, i) => callModel(c.prompt, c.history, i),
    });
  },
});
PropertyTypeDefaultDescription
maxCostnumber (USD)offHalt before a step.ai call once the run's summed cost reaches this.
maxTokensnumberoffHalt before a step.ai call once the run's summed tokens (in + out) reach this.

At least one axis is set. The ceiling is crossed by at most the one call that reaches it: a call's cost is unknown until it returns, so the call that pushes spend to the limit completes and the next one halts.

What you see. A capped run is an ordinary failed run in the console, carrying BudgetError as its terminal error - there is no separate "capped" state. In an agent loop, the halted turn is the loop's last (failed) iteration. Raise the cap and replay the run and it starts fresh with spend back at zero.

maxTokens always bites - tokens are metered from every model call. maxCost bites only when your runner prices its calls through a resolveCost map; Duraton holds no price list, so without one the cost axis stays inert.

budget - bound a workflow over a window

Where cap bounds one run, budget bounds the workflow's aggregate spend across all its runs over a rolling window. When a set axis is crossed, Duraton pauses the workflow's in-flight runs at their checkpoint - holding no runner, keeping their progress - and resumes them on its own when the window rolls forward or you raise the budget.

defineWorkflow({
  name: "support.autoreply",
  budget: { maxCost: 5, windowMs: 3_600_000, warnAtPct: 80 }, // $5/hour, amber at $4
  handler,
});
PropertyTypeDefaultDescription
maxCostnumber (USD)offPause the workflow's runs once its summed cost over the window reaches this.
maxTokensnumberoffPause the workflow's runs once its summed tokens (in + out) over the window reach this.
windowMsnumber (ms)requiredThe trailing window spend is summed over. Positive.
warnAtPctnumber (1-99)offA soft threshold the spend meter turns amber at, before the hard pause.

At least one axis is set. Duraton checks the budget on a background loop, so overshoot is bounded by one check interval (plus any per-run cap).

What you see. A budget-paused run is an ordinary paused run whose pausedBy.reason is "budget" - the console, GET /runs/{id}, and the get_run MCP tool all show it, so it is distinguishable from a manual pause. It resumes automatically; a manual resume is only an override, and a manually paused run is never auto-resumed.

tokenThrottle - bound the rate

A tokenThrottle protects your own provider quota: at most tokens spent per perMs across the runs sharing a key. Duraton debits each AI step's actual token usage after the step commits, and when the bucket is drained it delays new run starts for that key - the run waits in the queue holding no runner, then runs normally.

defineWorkflow({
  name: "enrich.contact",
  tokenThrottle: { tokens: 100_000, perMs: 60_000, key: "customerId" }, // 100k tokens/min per customer
  handler,
});
PropertyTypeDefaultDescription
tokensnumberrequiredThe token budget per window (in + out) across the runs sharing the key. Positive.
perMsnumber (ms)requiredThe window the token budget refills over. Positive.
keystringwhole workflowAn event-data path (e.g. "customerId", "user.id"); each value gets its own independent rate.

Because a step's tokens are known only after it runs, the throttle gates a run's start on the key's recent usage: a key that has recently spent heavily has its next runs spread out, a fresh key starts immediately. It shapes the rate of starts, not any single run - pair it with a cap to also bound one run.

What you see. A throttled run sits in queued with a future start time, then runs normally. There is no new state to handle.

cache - don't pay twice for the same call

The inference cache is the one control that reduces spend rather than bounding it. On a hit the provider is never called, so the step commits with zero spend and counts nothing against cap, budget, or tokenThrottle. Where step memoization makes a replay free, the cache makes an identical call in a different run free too.

const answer = await ctx.step.ai.generate("answer", {
  model: "claude-opus-4-8",
  prompt: `Answer from this policy doc:\n${doc}\n\nQ: ${question}`,
  temperature: 0, // required - caching engages only for a deterministic call
  cache: { ttlMs: 3_600_000 },
});
PropertyTypeDefaultDescription
cacheboolean | CacheOptionsofftrue opts the call in with the defaults; an object overrides them.
cache.ttlMsnumber (ms)86_400_000 (24h)How long an entry stays servable.
cache.seedstringyour app nameScopes entries further; entries never cross a project boundary.

The key is an exact match over the seed, the provider, the model, the prompt, and every output-affecting parameter (system, temperature, maxTokens, output), so a changed prompt or model never returns a stale answer. apiKey is never part of the key.

Caching engages only when temperature is explicitly set to 0.2 or lower. An unset temperature is treated as non-deterministic (a provider default is often 1.0), so cache: true with no temperature is a documented no-op - the call runs and is charged.

What you see. A cache-served step shows a cache pill in the run inspector carrying hit, key, and ageMs, with zero tokens recorded. The console's AI view has a cache hit rate for the window.

fallback - survive a rate-limited model

A fallback chain keeps one call alive when a model is rate-limited or down. The primary model is tried first; a retryable failure (429, a 5xx, or a timeout) advances to the next candidate, and the first to return wins. Its result is the step's durable output, so the caller never sees the failover.

const answer = await ctx.step.ai.generate("answer", {
  model: "claude-opus-4-8",
  prompt: question,
  fallback: [{ model: "claude-sonnet-4-6" }, { model: "claude-haiku-4-5" }],
});
PropertyTypeDefaultDescription
fallbackFallbackCandidate[]offBackup models tried in order after model.
fallback[].modelstringrequiredThe candidate model id.
fallback[].providerProviderNamethe call's providerThe candidate's provider, so a chain can span providers.

Only 429, 5xx, and timeout advance the chain. A terminal 4xx (a malformed request, an auth failure) fails the step immediately - another model will not fix a bad request - and an exhausted chain fails the step too, re-throwing the last error so the workflow's own retry policy still applies. Falling back to a cheaper model changes what the call costs, so a chain interacts with cap and budget through whichever model actually served.

What you see. The step shows a chain pill carrying chain (the models tried, in order), used (the one that served), and reason (why the chain advanced, e.g. "claude-opus-4-8: 429").

Watching spend

The console's Budgets view is the single place these controls surface together: every workflow's configured budget, tokenThrottle, and cap with a live meter of its window spend against the ceiling (amber at warnAtPct), plus the inference cache's hit rate. It is read-only - a control is declared in workflow code and enforced by Duraton, so there is nothing to create there.

For totals rather than ceilings, the AI view carries the window's spend, tokens, average latency, and cache-hit rate, broken down by hour, by model, and by workflow. An agent reads the same numbers through the ai_spend MCP tool.

On this page