← Writing

Your agent will do something expensive. Where does it have to ask?

Every agent that can act can act badly. Most stacks answer that with an alert, which arrives after the money is gone. This is the difference between alerting and enforcement, why only the thing running the agent can stop it, and the three places to draw the line.

Your agent can call tools. That is the whole point of it. It can also call the same tool four hundred times in ninety seconds, because a parser hiccupped and the loop never learned to stop.

So: where is the line it must not cross on its own?

Most teams cannot answer that question about their own system. Not because they do not care, but because nothing in the stack takes an answer. There is a dashboard. There is maybe an alert. There is no place to write down "not past here."

The problem: an alert is a description of something that already happened

An alert fires when a threshold is crossed. That is a past-tense fact. The spend has been spent. The email has been sent. The record has been deleted.

Between the crossing and your reaction there is a gap, and the agent is running the whole time. Something has to close it. A monitor cannot: it is outside the process, and the only lever it has is telling you.

Being told is not a control.

Agitate: what this actually costs

This is the part worth sitting with, because the fix looks like overkill until the failure is concrete.

The loop that does not stop

The runaway is not exotic. A tool returns something the model misreads. The model retries. The tool returns the same thing. Nothing in that cycle is an error, so nothing in your error handling notices. A dev.to write-up on production agent patterns puts the outcome plainly: "Without a circuit breaker, a single broken tool can burn your entire daily API budget in minutes."

Now do the arithmetic on a ten-step agent. If each step independently succeeds 85% of the time, the run succeeds about 20% of the time. That is not a model-quality problem you can prompt your way out of. It is what multiplication does. The interesting question stops being "will something go wrong" and becomes "what happens when it does."

The alert arrives after the money is gone

Put a timeline on it.

t+0s     the loop starts
t+40s    spend crosses your threshold
t+2m     the metric is scraped, the alert fires
t+6m     someone reads it
t+6m     the agent has been calling the model for six more minutes

Every one of those minutes is billable, and some of them are irreversible. The alert did its job. Its job was never to stop anything.

The workaround everyone writes, and why it breaks

The obvious fix is a counter in the handler:

let spent = 0;
for (const turn of turns) {
  const result = await callModel(...);
  spent += price(result.usage);
  if (spent > LIMIT) throw new Error("over budget");
}

This works right up to the moment anything restarts. The counter lives in memory. The process dies, comes back, starts the run over, and the ceiling resets to zero. You did not build a limit. You built a limit per process lifetime, which is not a number anyone can reason about.

It also only has one outcome: throw. Sometimes throwing is right. Often what you want is "hold here, and continue when the hour rolls over," and a counter in a loop cannot express that, because the process is holding the work. Pausing means blocking a worker.

The approval bolted on from outside

The same shape shows up when a human is involved. You want the agent to stop before issuing the refund.

Bolt it on and you get one of two bad outcomes. Either the run blocks while it waits, holding a worker for the forty minutes it takes someone to get back from lunch. Or you drop out of the run, write a row to a table, poll it, and re-enter later. Now you own the correlation, the resume, and the idempotency, and on the first retry after a crash the refund fires twice.

Getting the resume exactly right is genuinely hard, and not only for people rolling their own. It is an open bug in a mature agent framework: a human approves a tool call with edits, the edited call executes, and then the agent re-evaluates its state and re-attempts the original call - so the operator is asked to reject the very action their edit was supposed to replace. That is the correlation-and-resume problem, and it bites the people who have thought hardest about it.

The bill you actually pay

The money is the visible part. Two others cost more.

Irreversible actions. A refund issued, a bulk email delivered, records deleted. There is no rollback for any of them, and "we caught it in the dashboard" is not an answer anyone accepts.

The agent nobody turns on. This is the real one. A team that cannot answer "where does it have to ask" ships the agent in suggest-only mode and leaves it there. All the capability, none of the autonomy, because nothing in the stack made autonomy safe to grant. The agent runs at a fraction of its value forever, and it never shows up as an incident.

The solution: put the line where the run lives

The fix is not a better monitor. It is that the thing executing the agent has to be the thing enforcing the limit, because only it can suspend the run and pick it up later.

Three mechanisms, and they answer "where does it ask" differently. That difference matters more than any of them individually.

MechanismWho decidesWhat happens
capnobody, it is automaticHalts before the model call that would cross the ceiling. The run fails.
budgetnobody, it is automaticIn-flight runs pause at their checkpoint and resume on their own.
ctx.step.approvala personThe run parks until someone approves, denies, or edits and approves.

Two guardrails and one human decision. At 3am there is nobody to ask, which is exactly why the first two must not ask.

A ceiling on one run

defineWorkflow({
  name: "research.agent",
  cap: { maxCost: 0.25, maxTokens: 40_000 },
  handler,
});

The run halts before the step.ai call that would cross the line. That call never runs. Then the run fails, carrying a BudgetError as its terminal error, and everything committed before the halt stays committed. Raise the cap and replay it and it starts fresh with spend back at zero.

Note what this is not. It does not ask anyone. It is a fuse, and a fuse that phoned you before blowing would be a worse fuse.

A ceiling on a workflow over a window

defineWorkflow({
  name: "support.autoreply",
  budget: { maxCost: 5, windowMs: 3_600_000, warnAtPct: 80 }, // $5/hour, amber at $4
  handler,
});

cap bounds one run. budget bounds every run of the workflow over a rolling window. Cross it and in-flight runs pause at their checkpoint, holding no worker, keeping their progress. They resume on their own when the window rolls forward or when you raise the budget.

This is the outcome the in-memory counter could not express. Nothing failed. Nothing was lost. The work is waiting.

There is a third, quieter one: tokenThrottle protects your provider quota rather than your wallet, by spreading out new run starts for a key that has recently spent heavily. It shapes the rate; it does not bound a single run.

A step that asks a person

const decision = await ctx.step.approval<RefundArgs>("refund-gate", {
  tool: "issue-refund",
  args: { orderId, amount, currency: "usd", reason: "billing_error" },
  risk: "high",
  summary: `Refund ${amount} to ${orderId} for a duplicate charge`,
  escalatesTo: "#support-leads",
  timeout: "30m",
});

if (decision.status === "denied") return { outcome: "denied" };

// decision.args are the effective args: the decider's edits when changed, else the proposed ones.
const refund = await ctx.step.run("issue-refund", () => issue(decision.args));

The run suspends. It keeps its checkpoint and holds no worker while it waits, so a run parked for three days costs the same as a run parked for three seconds. The approval shows up in an inbox with the proposed call, its risk, and an editable view of the arguments. Approve, deny, or change the numbers and approve.

Three details that are easy to get wrong and are worth checking in anything you evaluate:

  • The decision resumes the run at exactly that step. The steps before it do not run again, because their results were already recorded. The refund fires once.
  • A timeout never decides. When the deadline passes the approval is marked overdue and the escalation target is surfaced. It stays open, the run stays suspended, and it resumes only on a real decision, however late it arrives.
  • Editing is a first-class outcome, not a rejection. The decider halves the refund and the run continues with the halved amount as decision.args.

Why this cannot be bolted on from outside

All three sit on the same machinery: a run that is suspended holds nothing and survives a restart. It is the same mechanism behind a durable sleep or a wait for an event. The only difference is what it waits on: a timer, an event, a budget window, or a person.

Something watching from outside can observe every one of those moments. It cannot occupy any of them, because it does not hold the run. That is the whole distinction between alerting and enforcement, and it is architectural rather than a matter of effort.

You do not have to rewrite your agent to get it. ctx.step.ai.wrap makes a model call you already wrote durable with no other change to the call site, and your framework keeps deciding what the agent does.

Where does yours have to ask?

Concretely, list the tool calls your agent can make. For each one, two questions:

  1. If this fires a hundred times in a row, what is the damage? If the answer is money, that is a cap and a budget. Nobody needs waking up.
  2. Can I undo it? If not, that is an approval. Refunds, deletions, outbound messages to real people, anything touching production infrastructure.

Anything that survives both questions can run unattended, which is the actual goal. The point of writing the line down is not to slow the agent. It is to make the rest of it safe to leave alone.

What this does not do

Worth saying plainly, because governance copy usually skips it.

  • cap's cost axis needs a price list you supply. maxTokens always bites, because tokens are metered on every model call. maxCost only bites when your runner prices its own calls, because Duraton holds no price list of its own.
  • The ceiling is crossed by at most one call. A call's cost is unknown until it returns, so the call that reaches the limit completes and the next one halts.
  • budget overshoot is bounded, not zero. The budget is checked on a background loop, so overshoot is bounded by one check interval, plus whatever a per-run cap allows.
  • The audit log covers control actions. Approvals, denials, cancels, pauses, resumes, replays, each recorded with the name of the API key behind it. It is not a record of everything the agent did, and the write is best effort, so a failed audit write never fails the action it describes.
  • There is no agent-framework plugin. Duraton is framework-neutral and there is nothing to rewrite, but no installable integration package for any framework exists today. The seam ships; the package does not.

Duraton is in beta. The docs for all of this are at docs.duraton.dev: cost controls and approvals.

If you have drawn this line in your own stack, I would like to know where you put it, and what you found out afterwards that moved it.

NextCheckpointing is not completion