How it works Spend caps & metering

Docs How it works

Spend caps and metering

Hard max_per_run_usd and max_per_day_usd caps, enforced by the runtime against cost metered at the MCP gate. Read the scope before relying on a cap.

On this page
Explainer · 0:45 · Spend caps · English captions
Transcript

0:00 An agent stuck in a loop can burn money fast. Constle keeps the meter outside it. Every tool call through a priced MCP server is metered at the gate, from the usage the server reports. Cross max_per_run_usd, and the gate trips: spending_limit_reached. The run is killed. max_per_day_usd persists across runs, keyed to the agent's DID. Once today's budget is spent, the next run won't start. Scope, honestly: only priced MCP servers are metered. Plain HTTPS through allowed_hosts isn't — and validate tells you so. A hard cap, enforced outside the agent.

What is metered, and what is not

Limits are enforced against cost metered at the MCP gate proxy, for servers that declare a pricing block. Nothing else is metered. Traffic through sandbox.network.allowed_hosts, including every direct call to an LLM API, is allowlisted and logged but not counted: a cap declared without a priced MCP server measures nothing at all. See limitations 2 and 3.

How metering worksLink to this section

Every declared MCP server is reachable only through the gate proxy. When a server declares pricing, the gate meters every tools/call response from it and charges the cost against the caps in spending:

mcp:
  servers:
    - id: web-search
      url: "https://mcp-search.example.com/mcp"
      tools: ["search"]
      pricing:
        meters:
          - usage_path: "result.usage.input_tokens"
            usd_per_unit: "0.00000300"
          - usage_path: "result.usage.output_tokens"
            usd_per_unit: "0.00001500"

spending:
  max_per_run_usd: "0.50"
  max_per_day_usd: "5.00"   # requires identity.did
  • usage_path is a dot-separated path into the full JSON-RPC response; a digit segment indexes an array (result.content.0.usage.input_tokens). No wildcards: the bill never depends on a fuzzy match.
  • usd_per_unit is an exact decimal string with at most 8 decimal places, never a YAML float: a rounding error in a spending cap is a security bug. Internally all money is integer micro-cents.
  • The cost of one response is the sum over all meters, because real APIs price input and output differently.
  • Pricing is server-wide. A response missing a declared usage value is a metering failure that kills the run: a server that could omit its usage field could zero its own bill. To mix free and priced tools from one upstream, declare its URL twice under two ids with disjoint tools allowlists, one priced and one not.
  • A pricing block with an empty meters list is rejected: it would read as "priced" while metering nothing.

The capsLink to this section

Field Scope Enforcement
max_per_run_usd one run Enforced. Crossing it kills the run through the same path as max_duration_seconds and records spending_limit_reached.
max_per_day_usd one UTC day, across runs Enforced, durably. Requires identity.did.
max_per_month_usd a month Declared only. Parsed and validated, never enforced; declaring it prints a warning.
alerts.warn_at_pct_of_daily a threshold of the daily cap Enforced, non-blocking. Writes a one-time warning to the audit log when the day's total first crosses it.

All amounts are exact decimal strings. A cap of "0" is rejected as ambiguous: omit the field to leave a limit unset.

When a run is killedLink to this section

The cap trips when the running total exceeds it. Metering is post-hoc (a response's cost is only known once the response has arrived), so the charge that crosses the cap is still incurred and still recorded. The ledger records reality; enforcement stops what happens next.

The daily ledgerLink to this section

max_per_day_usd is tracked in ~/.constle/spending/<did>/ under a file lock, so concurrent runs of the same identity share one ledger. It is keyed by DID, and rejected without identity.did, because keying it by name would let a rename reset it. Two behaviours follow:

  • A run whose accumulated daily spend already meets or exceeds the cap is refused before the sandbox starts, with a spending_limit_reached event recording action: run_refused.
  • An unreadable ledger is a hard error, never treated as $0 spent.

The warnings you will seeLink to this section

constle validate and constle run say so whenever a cap would not do what it looks like it does:

Situation Warning
Limits declared, no priced MCP server Limits are not enforced: nothing to meter
Limits declared, priced servers present, allowed_hosts non-empty Limits cover only the priced servers; allowed_hosts traffic is unmetered
Priced servers present, no limits declared Usage is metered but nothing is enforced
max_per_month_usd declared Not enforced by this version

The quickstart example hits the first row, which is why its run summary prints NOT ENFORCED.

Why allowed_hosts traffic is not meteredLink to this section

Metering that traffic would require Constle to TLS-intercept the agent's connections, letting the runtime read everything the agent says to every allowlisted host, which is far beyond what cost metering needs. Constle refuses to do that, and states the consequence instead: an agent that spends money over allowed_hosts rather than through a priced MCP server has no spending enforcement at all.

Every field, with its validation rules: Field reference §10.4 and §12.

Something wrong or unclear? Open an issue. The specifications on these pages are copies of spec/ in constle/constle.