Analytics & cost
Two questions, one data set: how are the calls going and what did they cost. Both are answered from the same per-call facts, so a cost figure can always be traced back to the second of the call that produced it.
Querying
POST /api/v1/analytics/query metrics, dimensions, filters, granularity
GET /api/v1/analytics/overview the dashboard's headline figures
GET /api/v1/analytics/vocabulary what you are allowed to ask forStart with vocabulary. It returns the metrics, dimensions and filters the store actually supports, which is a shorter list than you might guess and saves a round of trial and error.
curl -X POST "$CALLAGENT_API_URL/analytics/query" \
-H "Authorization: Bearer $CALLAGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"metrics": ["call_count", "avg_duration_seconds"],
"dimensions": ["assistant_id"],
"granularity": "day",
"from": "2026-01-01",
"to": "2026-01-31"
}'const report = await fetch(`${process.env.CALLAGENT_API_URL}/analytics/query`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CALLAGENT_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
metrics: ['call_count', 'avg_duration_seconds'],
dimensions: ['assistant_id'],
granularity: 'day',
}),
}).then((response) => response.json());import os, requests
report = requests.post(
f"{os.environ['CALLAGENT_API_URL']}/analytics/query",
headers={"Authorization": f"Bearer {os.environ['CALLAGENT_API_KEY']}"},
json={
"metrics": ["call_count", "avg_duration_seconds"],
"dimensions": ["assistant_id"],
"granularity": "day",
},
timeout=30,
).json()The exact request and response shapes are on the analytics reference.
Two paths, one answer
The store reads from materialised rollups where it can and from raw call facts where it must — percentiles, histograms, funnels and cross-dimension filters cannot be answered from a rollup. Percentiles are real percentile_cont percentiles, not an average dressed up.
Rollups are built on a schedule: hourly, and a daily pass at 02:30. A query covering the last few minutes therefore falls through to the raw path, which is slower and correct, rather than returning a stale rollup.
The dashboard, the boards and the REST API all read through the same store, so a number on a wallboard and the same number from the API cannot disagree.
Cost accounting
Money is metered per component, not per call.
GET /api/v1/calls/{call}/costs one call, broken down
GET /api/v1/billing/usage usage events
GET /api/v1/billing/usage/summary aggregatedEach row records the component, the provider, the model, the quantity in the provider's own unit, the unit cost, and both the provider cost and the customer charge.
Two component vocabularies, and the API uses the PHP one
call_costs.component — what GET /calls/{id}/costs returns — takes these ten values:
stt · llm · tts · s2s · telephony · recording · storage · tool · mcp · platform
CostComponentSchema in @callagent/contracts is a different set: transcriber, model, voice, speech_to_speech, telephony, storage, tool, mcp, knowledge, platform. Note llm against model, stt against transcriber, recording present only in PHP and knowledge only in TypeScript. Read the API's ten.
The summary block on the costs response groups them further still, into stt, llm, tts, telephony and other.
Twelve units are recognised: input_tokens, cached_input_tokens, output_tokens, reasoning_tokens, characters, audio_seconds, audio_minutes, call_minutes, requests, messages, gigabytes, gigabyte_months.
Amounts are decimal(20,8). An LLM token costs a fraction of a millionth of a dollar, and rounding early makes a ledger that cannot be reconciled.
What "priced" means
This is the part worth understanding before you build a margin report on it.
A stage is priced only from a registry row flagged pricing_verified. Where no verified price exists the cost row is still written — so the quantity is not lost — but with cost = 0 and metadata.priced = false. Every quantity also records its quantity_source, so you can tell a measured token count from an estimated one.
The call cost breakdown surfaces partial coverage rather than presenting an under-count as a total.
Currency is only converted when an FX rate is configured; otherwise figures stay in USD rather than being converted at a rate nobody chose.
Costing is idempotent — a deterministic key per component per call — so a retried finalisation cannot double-bill.
The margin floor
A minimum platform margin per billable minute. The formula is target = floor per minute × billable minutes, earned = Σ(charge − provider cost), and the top-up is max(0, target − earned), posted as one platform_margin usage event and idempotent per call.
It is a price row on the catalogue product platform.margin_floor, effective-dated and overridable per customer — not a constant in a config file. The seeded default is 0.10 EUR and 0.10 USD per minute, as independent list prices rather than one converted at an FX rate.
Whether operator talk time counts is config: the default ai_only excludes it from the billable-seconds base and does not let the operator line's own margin clear the floor; whole_call includes both.
Provider cost versus price
Provider cost is what CallAgent paid; customer charge is what you pay; the margin is recorded alongside. With BYO keys the provider bills you directly and CallAgent charges only its platform fee — both figures are still recorded, because "why is my bill this number" needs both.
Latency analytics
GET /calls/{call}/metrics returns the 25 metric names per turn. Aggregated, they are what tells you whether a provider change helped. The five with platform targets are listed on How a call works.
Monitors and issues
GET /api/v1/monitors
POST /api/v1/monitors
POST /api/v1/monitors/{monitor}/evaluate
GET /api/v1/issues
POST /api/v1/issues/{issue}/acknowledge
POST /api/v1/issues/{issue}/resolveA monitor watches a metric against a comparator and a threshold over a window, with a minimum sample size, a cooldown and an auto-resolve period. Breaching one opens an issue with a severity and an occurrence count; issues are acknowledged and resolved rather than merely notified, so a recurring problem is one row rather than four hundred alerts. Monitors are evaluated every five minutes.
Scorecards, evals and simulations
GET /api/v1/scorecards POST /api/v1/scorecards
GET /api/v1/eval-suites POST /api/v1/eval-suites/{evalSuite}/run
GET /api/v1/simulation-suites POST /api/v1/simulation-suites/{simulationSuite}/runA scorecard is weighted metrics with a pass threshold, scoped to assistants or campaigns. An eval suite replays fixed cases and asserts on the result. A simulation suite runs personas against the assistant with mock tools and success criteria.
Where scorecards run today
The scorecard engine is real and is used when a simulation runs. Scoring a live call depends on the post-call analysis pipeline below, which is not currently dispatched.
Post-call analysis
analysisPlan on an assistant configures a summary, a sentiment reading, topic extraction, a success evaluation with a rubric, and any structured outputs to extract.
Not currently produced by the control plane
The plan is stored, versioned and delivered to the gateway, and the columns and the analysis.completed event that would carry the results all exist. But the control plane's own analysis pipeline is not dispatched anywhere after a call finishes — the finaliser runs redaction, artifacts, post-call transcription and retention, and stops.
In practice calls.summary is populated only if the gateway supplies it in the finalisation payload. Do not build a report that assumes summary, sentiment, success-evaluation or topics are present. Tracked on Implementation status.
Post-call transcription
artifactPlan.postCallTranscription re-transcribes the recording after the call, which buys accuracy that a streaming transcriber optimised for latency cannot give you.
One provider
The assistant form offers a provider from the registry, but Sonix is the only adapter implemented. Any other selection has no client behind it, and the interface says so rather than failing at run time.
Observability export
observabilityPlan configures a provider (langfuse, opentelemetry, custom_http), an endpoint, tags, metadata and a sampleRate, plus includePrompts and includeTranscripts.
Spans are not shipped
Sampling and tags take effect, and exporters for Langfuse and OTLP exist in the code — but nothing invokes them. The gateway is explicit about it in its own logs: "observability provider is configured but this gateway exports no traces to it; tags and sampling are applied, spans are not shipped."
Do not plan a Langfuse or OpenTelemetry integration around this yet. Use the event stream, GET /calls/{call}/metrics and GET /calls/{call}/debug instead — those are real.
Next
- Billing — the ledger the usage events feed.
- Calls & artifacts — the per-call view.