Skip to main content

Spend management

Seeing and capping what conversations cost​

Spend management makes sure one conversation can never cost you more than you are willing to pay for it. Parlant records what every turn used, per model and per provider. You add prices, so that usage becomes money. Then you set a session budget, and you decide what happens when a conversation crosses it: hand it to a person, switch to a cheaper agent, or just flag it. You decide, not the model.

What you'll learn
  • How to read each turn's usage, per model and per provider
  • How to turn tokens into cost with a price table
  • How a session budget measures spend and resets
  • How to hand a conversation to a person when a budget is crossed
Talk this page through with your coding agent

Before you can set a limit, you need two things: to know what your conversations actually cost, and to decide what should happen when one exceeds it. Parlant records model usage for you automatically. On top of that, you can add pricing, measure a session against a budget, and use the result to pause automation or request human help.

Each of these is a separate layer, and each answers one question:

QuestionLayer
How many tokens did each turn use?Usage records, always on
What did that cost?Pricing
Has a session crossed a limit?Session budgets
Should the engine stop spending?Enforcement, through an observation or a circuit breaker

Let's walk through them from the bottom up.

Reading usage​

Usage records give you a per-turn account of what the model consumed, without you having to instrument anything.

Every turn has a trace id, the identifier shared by everything that turn produced, from the customer message to the reply. Parlant records the turn's usage under it.

To read it, call GET /sessions/{id}. The response includes a usage mapping keyed by trace id, with one entry per turn. Each entry carries request counts and the input, cached input, and output tokens per model.

Models are keyed as provider:model, the same spelling as a model route: anthropic:claude-sonnet-5-5, gemini:gemini-3.1-flash-lite, or openrouter:anthropic/claude-sonnet-5.5. The provider part is the id the adapter reports under: a built-in provider's prefix (openai, gemini, anthropic, openrouter, litellm), or the name you gave an adapter you built. The same model reached through two providers is counted, priced, and reported apart.

Note that session listings omit this mapping. To see a session's full usage history, fetch that specific session.

Note also that a new session already carries one usage record before any message arrives. When a session is created, Parlant warms the model's prompt cache with the agent's stable system prompt, so the first real turn is cheaper.

That warm-up is a paid request with its own trace id, which matches no event. Expect it on every new session once the matcher has enough rules to fan out.

Hedged requests count twice

To protect responsiveness, Parlant sometimes hedges a stalling model request. It fires an identical duplicate and uses whichever finishes first. Both attempts are billable, so Parlant counts both. This means a hedged turn's usage runs higher than its visible output would suggest.

Including paid tools​

A tool can return costs={"Address checks": Decimal("0.02")} in its p.ToolResult. These are incurred USD amounts, separate from model prices. Several tools can share a category, and one tool can report several categories. They count toward session budgets and the Dashboard's Spend totals under Tools; they do not change Parlant Billing or usage allowances.

Tool-only work can be priced without a model price table. If you also configure model prices, use USD: Parlant does not mix tool-reported USD with another server currency. Unpriced model work still makes the combined cost unavailable rather than producing an incomplete total.

Asynchronous responses shows how to record a late result and its costs with session.emit_tool_result before sending a follow-up with agent.utter. That cost is saved even if the follow-up fails.

Turning tokens into cost​

Now that you can see how many tokens each turn used, the next question is what those tokens cost. Usage records only count tokens; to turn tokens into money, Parlant needs prices from you.

You give the server a price table with set_pricing: one p.ModelPricing per model, priced per one million units. From then on, every turn's record carries a costs entry. The numbers below are sample prices; replace them with your provider's rates and reported model name:

from decimal import Decimal

import parlant.sdk as p

pricing = await server.set_pricing(
{
"gemini-3.1-flash-lite": p.ModelPricing( # the name the provider reports
input_tokens=Decimal("0.10"),
cached_input_tokens=Decimal("0.025"),
output_tokens=Decimal("0.40"),
),
},
currency="USD", # every cost and budget on this server uses it
)

A price table key takes one of two forms. A bare model id, such as "gemini-3.1-flash-lite", prices that model under any provider. A provider:model key, such as "openai-eu:gpt-5.5", prices that model only when that one provider serves it. When both are present, the exact provider:model entry wins for that provider, and the bare entry covers every other provider.

Model names must match what the provider reports, exactly. For gateways, the model part includes the gateway's own path, as in openrouter:openai/gpt-5.5. If a turn uses a model with no matching entry, the error names the full provider:model key. If your provider bills extra meters, such as separately billed reasoning tokens, ModelPricing.extra is where you price them.

Note that a turn binds the prices that are active when its first usage arrives. So if you replace the table mid-conversation, only future turns pick up the new prices.

Each priced turn's costs carries a total, a per_model breakdown with the same provider:model keys as the usage, and a per_provider breakdown that sums them by provider. The session's top-level costs adds every turn up the same way. When tools report costs, per_tool adds their category breakdown and total includes models and tools. Tool costs do not add tokens. per_provider is null on records written before usage carried a provider.

Provider-reported charges​

Some providers do the pricing for you. OpenRouter and LiteLLM report their own authoritative charge with each request, so Parlant prices a turn made entirely of their operations without needing a table at all. A provider-reported charge always wins over your table, so those operations need no entry.

What Parlant never does is silently count an operation as free. If a turn includes an operation with no price and no reported charge, that turn gets costs: null and Parlant writes a configuration error to the log.

Session budgets​

Once you know what a session costs, you can put a limit on it. That is what a session budget is for.

A session budget is a named amount, created once on the server, that measures one session's spend and tells you when that spend has crossed it. Spend is counted from a baseline, a per-session mark that says where counting starts, so you can reset a budget without deleting any usage:

from datetime import timedelta

budget = await server.create_session_budget(
name="conversation-cap", # budgets are found by name after a restart
value=Decimal("2.00"), # in the pricing currency
# window=timedelta(hours=24), # optional: count only the last 24 hours
)

Without a window, the budget is cumulative: it counts everything since it was activated or last reset. With a window, it slides, counting only recent spend. That is the shape you want for a per-day cap.

The budget uses your price table's currency, or USD when you rely on provider-reported charges. The comparison is strict: spending exactly the budget value is still within it.

Measuring and resetting​

Note that a budget does not act on its own. It is an instrument you consult from code that runs inside a conversation. await budget.measure() gives you the current session's spend, and await budget.reset() starts counting again from zero.

If you measure a turn that contains an unpriced operation, Parlant raises PricingConfigurationError naming the provider:model key, rather than quietly under-counting.

Each session keeps its own baseline per budget. The first time Parlant evaluates a budget for a session, it sets the baseline to that session's latest usage, so the measurement is zero. This means introducing a budget never retroactively charges an existing conversation.

Acting when a budget is crossed​

Now that you have a budget to measure against, you need something to happen when it is crossed. Measuring by itself does not stop spending.

In this example, exceeding the budget should pause automated replies and put the session in the support queue. An observation is what connects the budget check to the handler that performs those actions.

Normally, Parlant judges an observation's condition with a model on each turn. Here, two parameters change that. evaluation_func replaces the model's judgment with a function of your own, and on_matched is a handler that Parlant runs when the observation holds.

budget.exceeded is that function. It is a deterministic code evaluator, so no model judgment is involved, and Parlant evaluates it at the very start of a turn, before any model work.

The handoff handler​

The handler below requests human help and pauses automated replies. A few terms first: support_desk is your ticketing integration, and its operators need a way to read and reply to the same Parlant session. Manual mode means the agent stops replying on its own. A label is a marker on the session that your operator tooling can list sessions by.

Ask your coding agent to connect support_desk to the ticketing system you use:

budget = await server.create_session_budget(
name="handoff-cap",
value=Decimal("2.00"),
)


async def handoff(context: p.EngineContext, match: p.RuleEvaluation) -> None:
amount = await budget.measure() # this session's spend since its baseline
context.logger.info(f"Handing off at {amount} {budget.currency}")

# Reset first, so the same spend can't retrigger if an operator hands the session back.
await budget.reset()

await p.Session.current.update(mode="manual") # the agent stops replying on its own
await p.Session.current.labels.add("needs-human") # the operator queue: GET /sessions?labels=needs-human
await p.Session.current.emit_message(
"This conversation is being transferred to a human agent. One moment."
)

await support_desk.create_ticket(
session_id=p.Session.current.id,
customer_id=p.Customer.current.id,
reason="Session budget exceeded",
)


await agent.create_observation(
condition="The session budget is exceeded", # for humans reading the rule; the code decides
evaluation_func=budget.exceeded,
on_matched=handoff,
)

Here is what happens on the first turn where the session's spend has crossed the budget. The observation matches, Parlant runs your handler, and the turn stops cleanly. The agent generates nothing. The customer sees exactly the verbatim message the handler emitted. From there, manual mode keeps the agent silent, and the label puts the session in the operator queue.

Learn more about handoff

Human handoff builds the operator queue that the needs-human label feeds.

Note that the handler decides the response; handoff is only the common choice. Your handler could instead add a label for review, switch the session to a cheaper agent, or notify a system and let the conversation continue.

Ordinary turn costs become durable when the turn completes. A completed background tool result is saved when session.emit_tool_result returns, independently of any later utter. So a cumulative budget reacts on the next customer turn after the crossing, not in the middle of the turn that crossed it.

Managing budgets from outside a turn​

So far, every budget call has run inside a conversation. Inside a turn, budget.measure() knows which session it is in. Operator tooling that runs with no active turn has no such session. It fetches one from the server and goes through its usage object instead:

session = await server.get_session(id=session_id)

amount = await session.usage.measure(budget)
await session.usage.reset(budget)

The typical moment to reset is when a human closes out a session and hands it back, so the agent resumes with a fresh count. Resetting advances only that budget's baseline. It deletes no usage and touches no other budget.

Note that Parlant identifies budgets by name. If you recreate them with the same names after a restart, Parlant reconnects them to the persisted per-session baselines.

Stopping a burst​

To bring it all together: a cumulative budget is a business limit, and it reacts between turns. That is the right shape for "no conversation may cost more than $2". It is not the right shape for a sudden spending burst inside a turn, or across a few rapid ones. That needs an operational control that denies chargeable work outright.

That control is a windowed budget with a circuit breaker attached, which is a Compass Pro feature. Both controls read the same priced ledger and combine cleanly: the handoff observation runs first, and the breaker gates whatever continues.

Learn more about circuit breakers

Session circuit breaker covers the breaker in detail.

Listening to usage reports​

If what you want is continuous metrics rather than limits, listen to the usage reports themselves. UsageReporter, from parlant.core.usage_reporter, is the component that receives every provider's report. Take it from the container in initialize_container and attach a listener. Parlant calls your listener with the trace id, the provider:model key the usage is accounted under, and the token counts:

container[UsageReporter].add_listener(
lambda trace_id, usage_key, usage: metrics.increment(usage_key, usage.input_tokens)
)

Note that Parlant swallows listener exceptions, because reporting sits on the model hot path. Keep your listeners fast, and queue any expensive work for later.