Get API key →

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.0
from 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.0
import { 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 result

Now 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.

Get API key →