Usage Statistics API
ChatWalaʻau keeps an append-only record of the tokens it spends.
GET /api/usage/summary reads it back and GET /api/usage/export downloads it as CSV.
In the app, the same numbers are on the
Token Usage Dashboard.
What is recorded
Every turn that calls a model, in every lane:
- chats in the web app (both ordinary chats and harness agents)
- Declarative Workflow runs -- one line per node that ran, in the chat and as a Pipeline job (since v0.154.0)
- Microsoft Teams conversations
- requests through the OpenAI-compatible API
- the background passes you never see: chat titles, user memory extraction, agent memory curation, ontology natural-language queries, and Teams meeting summaries
A turn that you interrupt is recorded too, with what it had already spent -- the tokens were billed whether or not you kept the answer.
Workflow runs, node by node
A workflow does not produce one line per run. Every node that calls its agent writes its own line, so you can see which step of a workflow is the expensive one:
{ "lane": "workflow", "kind": "node", "run_target": "Contract Review Flow",
"node": "summarize_step", "agent": "Summarizer", "run_id": "b41e...",
"model_calls": 3, "uncached_input_token_count": 2100, "output_token_count": 480 }
run_targetis the workflow's name,nodethe step'sidin your YAML,agentthe Prompt agent that step invoked.- A node inside a loop writes one line each time it runs.
run_idties the lines of one run together, including a run that paused to ask you a question and continued on your answer.- A workflow run as a Pipeline job uses
lane: "workflow-job", has no chat, and itsrun_idis the job id. - The chat title generated after a workflow's first reply is recorded as a chat-title
line carrying the workflow's name in
run_target.
In the chat itself, a workflow's reply shows the run total as its in/out label. Click it for the per-node breakdown and the context occupancy of the node that came closest to its model's window.
What is not recorded
- Demo mode. Its token counts are estimates rather than measurements, so recording them would put invented numbers into real statistics.
- A model call that fails before the provider reports usage. There is no number to record, in any lane.
Earlier versions listed Declarative Workflow runs here as not yet instrumented. They are now recorded, per node.
Earlier versions of this page, and the coverage field itself, also listed the
framework's context-compaction calls as unrecorded spending. That was wrong.
Compaction here trims history by token budget and never calls a model, so it costs
nothing to record. A summarizing compaction strategy would -- and none is
configured. Overstating a gap tells you to distrust numbers that were accurate.
Every response repeats this in a coverage field. These totals are what the
observable work consumed -- not what your account was charged.
Where it lives
# .env
USAGE_DIR=.usage # default
One file per month, one line of JSON per turn:
.usage/2026-09.jsonl
It is a separate directory from your chats on purpose. Deleting a chat, the temporary-chat cleanup, and chat export/import all operate on the session directory, and a spending record has to survive all three.
Nothing is ever deleted from the ledger, and there is no retention setting.
Reading it
curl -s -H "Authorization: Bearer $API_KEY" \
"http://localhost:8000/api/usage/summary?from=2026-09-01&to=2026-09-30&group_by=day"
| Parameter | Values | Default |
|---|---|---|
from / to | YYYY-MM-DD, inclusive, in tz | the last 30 days |
tz | an IANA time zone, e.g. Asia/Tokyo | UTC |
group_by | day, month, chat, model, lane, run_target, node, agent, provider, outcome, kind | day |
series | a second grouping key from the same list (not the same as group_by) | none |
lane | spa-prompt, spa-harness, workflow, workflow-job, teams, openai-api, helper | all |
group_by=node keys each group as "<workflow name> / <node id>", because the same
step id can appear in different workflows. group_by=run_target groups workflows (and
harness agents) by name.
Days in your own time zone (v0.157.0)
The ledger stores UTC. Pass tz and from, to and the day / month keys become
dates in that zone -- a record written at 2026-08-31T20:00Z counts toward
2026-09-01 with tz=Asia/Tokyo. Without tz everything is UTC, exactly as before.
Two axes at once (v0.157.0)
series splits every group again. group_by=day&series=model returns each day with a
series list, one row per model; the rows always add up to their day. Every group,
row and totals also carries first_ts / last_ts, the first and last activity in it.
{
"from": "2026-09-01",
"to": "2026-09-30",
"tz": "UTC",
"group_by": "day",
"groups": [
{
"key": "2026-09-04",
"records": 128,
"model_calls": 402,
"uncached_input_token_count": 51200,
"cache_read_input_token_count": 230400,
"output_token_count": 15360,
"reasoning_output_token_count": 6400
}
],
"totals": { "records": 128, "model_calls": 402 },
"skipped_lines": 0,
"coverage": "Observable model calls only. ..."
}
Exporting for BI (v0.157.0)
curl -s -H "Authorization: Bearer $API_KEY" -o usage.csv \
"http://localhost:8000/api/usage/export?from=2026-09-01&to=2026-09-30&tz=Asia/Tokyo"
The export is the raw record set -- one row per ledger line -- so your BI tool can
build any pivot. Parameters are from, to, tz and lane as above, plus
format=csv (the only format).
ts, ts_local, lane, kind, purpose, thread_id, temporary, model, provider,
run_target, node, agent, run_id, model_calls,
uncached_input_token_count, cache_read_input_token_count,
cache_creation_input_token_count, output_token_count,
reasoning_output_token_count, outcome
tsis the stored UTC time;ts_localis the same instant intz.- A value the provider did not report is an empty cell, not
0. - The column order is fixed. New columns are only ever added at the end, so an import you build today keeps working after an upgrade.
- UTF-8 with a BOM, so Excel shows non-English workflow and agent names correctly.
- A text value that a spreadsheet would run as a formula (starting with
=,+,-or@) is prefixed with'. - The
X-Usage-Skipped-Linesheader counts unreadable ledger lines;X-Usage-Coveragerepeats the coverage note. - There are no price columns.
Reading the numbers
model_calls is usually larger than the number of turns. A turn that uses tools
calls the model once per step, and each call is billed.
Input is split by price band, not merged. Cache reads and cache writes cost
different amounts from ordinary input, and once added together they cannot be
separated again -- so uncached_input_token_count, cache_read_input_token_count
and cache_creation_input_token_count stay distinct. A high cache-read share is
prompt caching working.
A field you do not see was not reported. If a provider reports no cache figures,
the field is absent rather than 0, so you can tell "none used" from "not measured".
Reasoning is part of output. reasoning_output_token_count is already counted in
output_token_count; do not add the two.
There are no prices here. Rates change independently of this system and depend on your contract; a stored price would go quietly stale. Multiply by your own rates.
Chats that leave no name
Temporary chats appear in day and month totals but group under (temporary) in the
per-chat view: the conversation is designed to disappear, so there is no chat to
attribute the spend to. The totals still add up.