I had left a verification session running overnight, and that morning was the first time I attached a spending ceiling to it. I meant to let it run up to twenty-five dollars, so I wrote "25" into amount. The session had stopped after a handful of turns, with nothing in the log but a stop reason of budget_reached.
I suspected the model. Then the environment. Only when I finally opened the SDK type definitions did it click. The amount is counted in minor units. My "25" was never twenty-five dollars — it was twenty-five cents.
An amount is not a number; it is a string counted in the currency's smallest unit. Keeping that one line in mind has been enough to stop me from repeating it.
Budgets travel as integer strings in minor units
A session budget is a small structure called BetaManagedAgentsBudgetLimit. Reading the type definitions in @anthropic-ai/sdk 0.124.0, it holds exactly two things.
| Field | Type | Meaning |
|---|---|---|
type | 'limit' | Fixed value |
max_list_cost | { amount, currency } | Where it stops |
The answer about units sits right in the doc comment on amount. It takes an integer in minor units, as a decimal string with no leading zeros. "2500" is $25.00 and "50" is fifty cents. The comment even explains the choice of a string: so that no float rounding is ever applied.
currency is an uppercase ISO-4217 code, and USD is the only one supported right now.
You can confirm all of this on your own machine in a couple of minutes, and I would rather you did that than take my word for it.
# No API key needed. This only reads the shipped type definitions.
mkdir -p ~/probe && cd ~/probe && npm init -y >/dev/null
npm install @anthropic-ai/sdk
grep -A8 "interface BetaMonetaryAmount" \
node_modules/@anthropic-ai/sdk/resources/beta/beta.d.ts
# Expected: the amount comment mentions "minor units" and '"2500" is $25.00'The package itself lives at @anthropic-ai/sdk on npm.
Creating a session with a budget attached
The minimum shape is short. The part that matters to me is wrapping the dollars-to-minor-units conversion in a function, so that a hand-typed "25" never gets the chance to appear again.
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
// Dollars in, minor-unit string out. One small gate against the mistake I made.
function usd(dollars) {
const cents = Math.round(dollars * 100);
if (!Number.isFinite(cents) || cents < 0) {
throw new Error(`Invalid budget: ${dollars}`);
}
return { amount: String(cents), currency: "USD" };
}
const session = await client.beta.sessions.create({
agent: "agent_xxxxxxxx",
environment_id: "env_xxxxxxxx",
budget: { type: "limit", max_list_cost: usd(25) }, // → amount: "2500"
betas: ["managed-agents-2026-04-01"],
});
console.log(session.budget);
// Expected output:
// { type: 'limit', max_list_cost: { amount: '2500', currency: 'USD' } }Print session.budget once after creation. If amount reads "2500", you got what you intended. If it still reads "25", the session is running under a ceiling one hundred times lower than you think. A single log line here would have saved me the whole detour.
If you want different ceilings per session on top of one shared agent, the shape of that arrangement is covered in running one shared agent definition with per-session overrides.
Two ways to learn that it stopped
A session that reaches its ceiling does not start new model requests; it goes idle. You can find out through either of two channels.
The first is the stop_reason on a session.status_idle event. The type definitions list four possible values, and budget_reached is one of them.
| stop_reason | State | Your move |
|---|---|---|
end_turn | Turn finished | Send the next message |
requires_action | Awaiting confirmation | Return a tool confirmation |
retries_exhausted | Retries used up | Hand it to a person |
budget_reached | Ceiling reached | Raise it or clear it |
const stream = await client.beta.sessions.events.stream({
session_id: session.id,
betas: ["managed-agents-2026-04-01"],
});
for await (const event of stream) {
if (event.type !== "session.status_idle") continue;
if (event.stop_reason.type === "budget_reached") {
const s = await client.beta.sessions.retrieve(session.id, {
betas: ["managed-agents-2026-04-01"],
});
// list_cost is in minor units too. Divide by 100 only for display.
const spent = Number(s.usage?.list_cost?.amount ?? 0) / 100;
console.log(`Budget reached: spent $${spent.toFixed(2)}`);
break;
}
}The second channel is a webhook. There is an event type named session.budget_reached, and the payload is deliberately plain: a session ID, an organization ID, and a workspace ID. If you want the number, fetch the session after the webhook arrives.
For anything running unattended, what has worked for me is to let the webhook wake a queue and leave the actual decision for hours when a person is looking. Raising a ceiling automatically at three in the morning quietly cancels the reason the ceiling existed.
The distinction between the two channels matters more than it first appears. The stream tells you the shape of the stop, because stop_reason is a tagged union and you can branch on it directly. The webhook tells you only that something happened, which is the right amount of information for a process that was not watching. As an indie developer running scheduled jobs against a handful of small products, I lean on the webhook for the alerting path and reserve the stream for the sessions I am actively debugging. Holding a stream open all night to catch an event that may never arrive has never paid for itself in my setup.
One more detail worth reading carefully: list_cost on the usage snapshot is optional. A session that has not yet been priced returns nothing there, so treat a missing value as unknown rather than as zero. Printing $0.00 for a session that actually spent something is the kind of quiet wrongness that survives for months.
Raising it, versus clearing it altogether
There are two ways back to a running session. The obvious one is to lift the ceiling.
await client.beta.sessions.update(session.id, {
budget: { type: "limit", max_list_cost: usd(50) }, // $25 → $50
betas: ["managed-agents-2026-04-01"],
});Here the type definitions carry a caveat I had not anticipated. budget_reached is returned not only when the money runs out, but also when the session's usage includes a model that has no list price — something a budget cannot measure. In that case, a request to raise the ceiling is rejected. Adding headroom to something unmeasurable buys nothing, so if you want to continue, you remove the budget instead.
// null is accepted on update only; the create parameter does not take null.
await client.beta.sessions.update(session.id, {
budget: null,
betas: ["managed-agents-2026-04-01"],
});The budget field on create and the one on update have subtly different types. Create takes a value; update also accepts null. Trying to express "remove this" through the create parameter will not go through, so clearing always belongs on the update path.
A budget on a deployment is not a total
Deployments accept the same budget structure, and this is the spot I misread first. I assumed it meant twenty-five dollars for the whole deployment.
It applies to each session started from that deployment, individually. A deployment carrying a $25 budget across twenty sessions is enforcing that ceiling twenty separate times. If you want a real cap on the sum, you still need something of your own that tallies spend and stops the work — the reasoning behind that is laid out in holding a hard spend ceiling in production with a circuit breaker, which is where I would start if the total is what keeps you up at night.
For today, print budget.max_list_cost.amount on the sessions you already have running. If it is short by two digits, that is the cheapest bug you will find all week.
Thank you for reading this far. A unit that silently divides your runway by a hundred is simple enough once you know it, and nearly invisible until you do. If this spares even one person that particular morning, it was worth writing down.