Task budgets, one ceiling for the whole job
Provider spend caps are real and they fire. What they are bound to is a project, an organization over a calendar month, or one session on that vendor's own harness. A task budget is bound to something else: what this job is allowed to spend, across every call that passes the same task_ref. Instrument a tool with that same task_ref and it draws down the same ceiling; one you do not instrument is invisible to it. Consulted before each call runs.
What a job counts
A job counts dollars, tokens or units, chosen when it opens and fixed from then on. In dollars, AgentBill prices each call itself: a $5 ceiling for the job is task_ceiling_usd=5 on the preflight that opens it (or ceiling_usd from the console or PUT /tasks/:task_ref/ceiling), a call you expect to cost 12 cents passes estimated_usd=0.12, and what it is charged is the public list price of the tokens your provider reported. A call it cannot price is charged its reservation, never $0. Every dollar figure is an estimate at list price, not your invoice.
In tokens, which is what wrap() records unless you open the job in dollars, the ceiling is the tokens your provider reported. In units, the default, the number is an integer you define, such as requests, documents or tool calls, and AgentBill never converts it to money; keep one unit for every call under the same task. The walkthrough below counts units, so every number in it is one you can see.
How it works
A task groups many calls under one ceiling. Three rules:
1, A job is opened with its ceiling: by the preflight that names a new
task_ref with a task_ceiling, or in the
console (or PUT /tasks/:task_ref/ceiling) before any code runs. After
that the console's last save is the ceiling in force; a later value from code is not applied.
2, Every later preflight atomically reserves against the same budget; the call that would
cross the ceiling is refused before it runs.
3, Records report reality: a failed run releases its reservation, and spend that lands past
the ceiling is still recorded and flagged task_exceeded, never
silently dropped.
Quick start, curl
# This call opens the task with a ceiling of 50 units curl -X POST https://agentbill.dev/preflight \ -H "Authorization: Bearer agb_your_key" \ -H "Content-Type: application/json" \ -d '{"agent_id":"researcher","estimated_units":2, "task_ref":"job-42","task_ceiling":50}' # ... run the LLM / tool call, then record what actually happened curl -X POST https://agentbill.dev/events \ -H "Authorization: Bearer agb_your_key" \ -H "Content-Type: application/json" \ -d '{"customer_id":"default","event_type":"llm_call", "idempotency_key":"job-42-step-1","units":2,"task_ref":"job-42"}' # The call that would cross the ceiling is refused: # {"approved":false,"reason":"task_ceiling_exceeded", # "task_used_units":48,"task_remaining_units":2}
Python
pip install agentbill-sdk # >= 0.4.0from agentbill import AgentBillClient, TaskCeilingExceededError client = AgentBillClient(api_key="agb_your_key") # preflight before, record after, or wrap it all with the gate decorator: @client.gate("researcher", estimated_units=2, task_ref="job-42", task_ceiling=50) def run_step(query: str) -> str: return call_llm(query) # the run that would cross 50 units raises TaskCeilingExceededError # a run that throws releases its reservation automatically
Node.js
npm install agentbill # >= 0.2.0import { preflight, record, getTask } from 'agentbill'
await preflight({ agentId: 'researcher', estimatedUnits: 2,
taskRef: 'job-42', taskCeiling: 50 })
// ... run the call ...
await record({ agentId: 'researcher', units: 2, taskRef: 'job-42' })
const t = await getTask('job-42') // live burn-down
console.log(t.usedUnits, '/', t.ceilingUnits)End to end: one job, many calls, everything that can go wrong
Every other example on this page is a fragment. This one is the whole loop, because the parts that matter are the ones a three line snippet leaves out: what a retry does to a reservation, what a crash does to it, and which call releases it.
import time
from agentbill import (
AgentBillClient, TaskCeilingExceededError, PreflightInProgressError,
)
client = AgentBillClient(api_key="agb_your_key")
TASK = "job-142" # one job. Every call below shares this budget.
CUSTOMER = "cust_abc"
CEILING = 500 # 1 unit = 1 cent here, so this job has 5 dollars
def guarded(step: str, units: int, work):
# Reserve, run, settle. Safe to retry, safe to crash.
for _ in range(3):
try:
client.preflight(
agent_id="researcher",
customer_id=CUSTOMER,
task_ref=TASK,
task_ceiling=CEILING, # opens the job if it is new; the console can change it
estimated_units=units,
idempotency_key=f"{TASK}:{step}", # stable across retries: one reservation
)
break
except PreflightInProgressError:
time.sleep(0.2) # another attempt holds the key
else:
raise RuntimeError(f"{step}: preflight never settled")
try:
result = work()
except Exception:
# Release the reservation, bill nothing. units must equal what
# preflight reserved, or the difference stays held until the TTL.
client.record(agent_id="researcher", customer_id=CUSTOMER,
units=units, task_ref=TASK, success=False)
raise
client.record(agent_id="researcher", customer_id=CUSTOMER,
units=units, task_ref=TASK, success=True)
return resultNow the job itself. A model call and a tool call, different providers, one ceiling between them. The retry loop is the failure this whole product exists for:
# Different providers, same budget. AgentBill never sees either one, # it sees the units you decided each is worth. notes = guarded("summarize", 12, lambda: call_model(prompt)) prices = guarded("fetch-prices", 3, lambda: call_tool("prices")) # The loop that used to run until morning: try: for page in range(1, 200): guarded(f"crawl-{page}", 4, lambda: call_tool("crawl")) except TaskCeilingExceededError as e: # Not an error to swallow. This is the product working. print(f"refused at {e.task_used_units}/{e.task_ceiling} units")
Two workers on the same task
Nothing above changes when two processes share a task_ref. The reserve is a single conditional UPDATE, so when eight units remain and both workers ask for eight, exactly one is approved and the other gets task_ceiling_exceeded. There is no window where both read the same remaining balance, and no application level lock to get wrong.
from concurrent.futures import ThreadPoolExecutor
# Same task, same last 8 units, two workers. One wins.
with ThreadPoolExecutor(max_workers=2) as pool:
for future in [pool.submit(guarded, f"final-{i}", 8, work) for i in range(2)]:
try:
future.result()
except TaskCeilingExceededError:
print("refused, the other worker took the last units")What happens if the process dies
If it dies between preflight() and record(), the units stay reserved: nothing else can spend them, and the job's remaining budget looks smaller than it is. A sweeper reclaims them once the reservation passes its TTL, which approved responses return as reservation_expires_at (default 60 minutes, set RESERVATION_TTL_MINUTES if your runs are longer). Note the direction: an abandoned reservation makes the ceiling tighter, never looser. The gate does not open by accident.
API reference
POST /preflight, extra fields
task_ref, job identifier (1-128 chars). Same ref = same budget.
task_ceiling, opens a new task_ref with that ceiling, required then
unless the job was opened beforehand from the console; not applied once the job exists. An approved answer
and a task_ceiling_exceeded refusal carry the ceiling in force as
task_ceiling.
idempotency_key, optional (1-128 chars). Same key = same decision,
one reservation, so a retried preflight cannot reserve twice. A retry that arrives while the
original is still being decided gets 409 preflight_in_progress,
which is not a refusal and reserves nothing.
Approved responses include task_remaining_units and
reservation_expires_at, the point after which the sweeper reclaims
the reservation. A refused call returns
reason: "task_ceiling_exceeded"; a new task_ref without a ceiling
returns 422 task_ceiling_required. A refused call reserves nothing
and burns no plan quota: every rejection rolls the whole transaction back.
POST /events, extra field
task_ref, attributes the spend to the task. success: false releases the reservation without billing. Responses include task_used_units, task_remaining_units and task_exceeded.
GET /tasks and GET /tasks/:task_ref
Per-agent cost attribution: every job's ceiling, spend, live reservations and overage flag. Filter with ?agent_id=.
PUT /tasks/:task_ref/ceiling
Opens a job with a ceiling, or changes one. Body: ceiling_units (required), agent_id (optional, read only when this opens the job). The last save through the endpoint or the console is the ceiling in force; a task_ceiling sent on a later preflight is not applied. A ceiling under the job's spent plus reserved units is 409 ceiling_below_committed with the smallest value that would be accepted; nothing in flight is rewritten.
Related guides
Task budgets, a hard cost ceiling per agent job How to cap what one agent run can spend Every setup failure, and its fix Integrations: the OpenClaw plugin, the MCP server and framework guides LangChain, one ceiling per job in middleware OpenAI Agents SDK, one ceiling per job in RunHooks CrewAI, one ceiling per crew run in model-call hooks