Skip to main content

Meter and Budget Your Project's Spend

Every time an agent completes a generation, SOAT records an append-only usage event with the provider's reported token counts. On top of that raw ledger you can price usage, roll it up per project, and get pushed a webhook the moment a project crosses a budget. In this tutorial you will:

  1. Log in and create a project.
  2. Create an Ollama-backed agent and run a generation.
  3. Inspect the raw usage meter and its token components.
  4. Read a receipt for the generation.
  5. Aggregate the project's usage by day and by model.
  6. Register prices so future usage carries a dollar cost.
  7. Set a budget threshold and subscribe a webhook to the usage.threshold_crossed alert.

By the end you will understand SOAT's "meter now, price forward" model and how to wire proactive budget alerts.

Prerequisites

  • SOAT running locally. Follow the Quick Start guide to bring the stack up with Docker Compose.
  • Ollama running locally with a chat model available (this tutorial uses qwen2.5:0.5b).
  • New to SOAT? Read Key Concepts first.
export SOAT_BASE_URL=http://localhost:5047

Step 1 — Log in as admin

Admin is the built-in superuser role. See Users for full authentication details.

ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN

Step 2 — Create a project

Usage is metered and budgeted per project.

PROJECT_ID=$(soat create-project --name "Metering Demo" | jq -r '.id')
echo "PROJECT_ID: $PROJECT_ID"

Step 3 — Create an agent and run a generation

Set up a local Ollama AI provider and an agent, then run one generation. The completed generation is what SOAT meters.

AI_PROVIDER_ID=$(soat create-ai-provider \
--project-id "$PROJECT_ID" \
--name "Local Ollama" \
--provider "ollama" \
--default-model "qwen2.5:0.5b" | jq -r '.id')
echo "AI_PROVIDER_ID: $AI_PROVIDER_ID"
AGENT_ID=$(soat create-agent \
--project-id "$PROJECT_ID" \
--ai-provider-id "$AI_PROVIDER_ID" \
--name "Metered Agent" \
--instructions "You are a concise assistant. Keep answers under 20 words." | jq -r '.id')
echo "AGENT_ID: $AGENT_ID"
GENERATION_ID=$(soat create-agent-generation \
--agent-id "$AGENT_ID" \
--messages '[{"role":"user","content":"Name three uses for a paperclip."}]' | jq -r '.id')
echo "GENERATION_ID: $GENERATION_ID"

Step 4 — Inspect the raw usage meter

Each completed generation records one usage event whose components carry the per-dimension token counts (input_tokens, output_tokens, cached_tokens, and a non-billable reasoning_tokens detail). cost_usd is null for now — you have not registered any prices yet, and SOAT ships none by default.

soat list-usage-meters --generation-id "$GENERATION_ID"

The meter is written a moment after the generation response returns. If the list is empty, re-run the command.


Step 5 — Read the generation receipt

A receipt rolls a generation's events into per-model line items with a by_meter_type split and reconstructed token totals — the shape you reconcile against a provider invoice. Pass run_id instead of generation_id to get the same shape summed across an orchestration run.

soat get-usage-receipt --generation-id "$GENERATION_ID"

Step 6 — Aggregate the project's usage

get-usage rolls the whole project up over an optional [from, to] window, bucketed by one dimension: model, agent, run, day, or meter_type. This is the per-project figure you would show on a dashboard — no client-side scan of raw meter rows.

soat get-usage --project-id "$PROJECT_ID" --group-by day
soat get-usage --project-id "$PROJECT_ID" --group-by model

Each group and the grand totals carry summed token counts and cost_usd (still null until you price the SKU next).


Step 7 — Register prices (they apply going forward)

Cost is computed at write time from the price effective when a generation runs, and prices are immutable and non-retroactiveeffective_from must be in the future, so a recorded cost is always explainable by the row that produced it. Register a rate for the Ollama SKU; every generation from effective_from onward will carry a cost_usd, while today's already-metered usage stays frozen at null.

soat upsert-price-book \
--prices '[{"provider":"ollama","model":"qwen2.5:0.5b","component":"input_tokens","unit":"token","unit_price":0.0000001,"effective_from":"2099-01-01T00:00:00.000Z"},{"provider":"ollama","model":"qwen2.5:0.5b","component":"output_tokens","unit":"token","unit_price":0.0000002,"effective_from":"2099-01-01T00:00:00.000Z"}]'
soat get-price-book

Prices resolve most-specific-first: a per-provider override, then a project + provider-slug rate, then this global default. See Usage → Pricing for the full resolution rules.


Step 8 — Set a budget threshold and subscribe to the alert

A UsageThreshold fires the usage.threshold_crossed webhook after any usage-event write once a project's windowed metric crosses it. Because tokens are always metered (with or without prices), a tokens threshold works immediately; a cost_usd threshold starts counting as priced usage accrues.

Create both, then subscribe a webhook so the alert is pushed to you.

soat create-usage-threshold \
--project-id "$PROJECT_ID" \
--metric tokens \
--window rolling_24h \
--threshold 1
soat create-usage-threshold \
--project-id "$PROJECT_ID" \
--metric cost_usd \
--window calendar_month \
--threshold 50
soat create-webhook \
--project-id "$PROJECT_ID" \
--name "budget-alerts" \
--url "https://example.com/usage-alerts" \
--events '["usage.threshold_crossed"]'
soat list-usage-thresholds --project-id "$PROJECT_ID"

The tokens/rolling_24h threshold above (limit 1) is already exceeded by Step 3's generation, so the next metered generation on this project fires the webhook. Delivery carries the standard signed envelope with this data:

{
"threshold_id": "uthr_V1StGXR8Z5jdHi6B",
"project_id": "proj_V1StGXR8Z5jdHi6B",
"metric": "tokens",
"window": "rolling_24h",
"window_key": null,
"threshold": 1,
"observed_value": 62
}

Re-fire is bounded by hysteresis so you are not spammed:

  • calendar_month fires at most once per YYYY-MM window (window_key identifies it); it re-arms at the month boundary.
  • rolling_24h re-arms only after the windowed value drops below 90% of the threshold (window_key is null).

Thresholds are immutable apart from deletion — to change one, delete and recreate it, which resets its fire state.


What you learned

  • Every completed generation is metered into an append-only usage event with per-dimension token components; cost_usd is null until a price covers the SKU.
  • Receipts reconcile a generation (or a whole run) against a provider invoice; get-usage rolls a project up by model/agent/run/day/meter type.
  • Prices are write-time and forward-only — you register rates that apply to future usage, and historical costs never change.
  • Thresholds push a usage.threshold_crossed webhook when a project crosses a token or cost budget, with once-per-window / 10% re-arm hysteresis.

See the Usage module for the full data model, pricing tiers, and permissions.