Business KPIs reference
This page is the exhaustive companion to Business KPIs: request and response examples, field tables and edge semantics. Start with the guide for what a KPI is, how to read one, and how to define one.
Failure impact — which failures are eroding a KPI
How the ranking works is in the guide.
Over the API
GET /kpis/{id}/eroding-clusters returns the same two lists the tab draws: ranked (worst first,
each with its impactShare) and unknown (the thin clusters, with a reason). Read access is enough.
curl -sf "$NEENS_BASE_URL/api/kpis/$KPI_ID/eroding-clusters" \
-H "Authorization: Bearer $NEENS_API_KEY"{
"kpiId": "a68e0cbb059f4b0ba38f409ee2145b19",
"measureKey": "containment_rate",
"unit": "ratio",
"direction": "higher_is_better",
"windowDays": 30,
"attributable": true,
"ranked": [
{
"clusterId": "cl_7f21a3",
"clusterLabel": "Refund policy mis-quoted, customer escalates",
"clusterSessionCount": 118,
"clusterStatus": "active",
"status": "known",
"unknownReason": null,
"clusterCases": 118,
"clusterDecided": 96,
"clusterValue": 0.5833,
"baselineCases": 812,
"baselineDecided": 704,
"baselineValue": 0.8731,
"erosionUnits": 27.82,
"erosionRate": 0.2898,
"ci": [0.485, 0.676],
"distinguishable": true,
"impactShare": 0.624,
"computedAt": "2026-08-19T02:14:07Z"
},
{
"clusterId": "cl_3b90e2",
"clusterLabel": "Order-lookup tool times out mid-conversation",
"clusterSessionCount": 160,
"clusterStatus": "active",
"status": "known",
"unknownReason": null,
"clusterCases": 160,
"clusterDecided": 140,
"clusterValue": 0.7536,
"baselineCases": 812,
"baselineDecided": 704,
"baselineValue": 0.8731,
"erosionUnits": 16.73,
"erosionRate": 0.1195,
"ci": [0.676, 0.820],
"distinguishable": true,
"impactShare": 0.376,
"computedAt": "2026-08-19T02:14:07Z"
}
],
"unknown": [
{
"clusterId": "cl_c14af8",
"clusterLabel": "Non-English greeting not recognised",
"clusterSessionCount": 11,
"clusterStatus": "active",
"status": "unknown",
"unknownReason": "insufficient_decided",
"clusterCases": 11,
"clusterDecided": 7,
"clusterValue": 0.5714,
"baselineCases": 812,
"baselineDecided": 704,
"baselineValue": 0.8731,
"erosionUnits": null,
"erosionRate": null,
"ci": [0.25, 0.84],
"distinguishable": false,
"impactShare": null,
"computedAt": "2026-08-19T02:14:07Z"
}
]
}Reading it:
| Field | What it tells you |
|---|---|
attributable | true for a rate or cost KPI; false for a percentile, where both lists are empty and the tab says erosion isn’t defined for it. |
ranked | The clusters with a defensible erosion, worst first (largest erosionUnits). |
unknown | The thin clusters, listed apart with an unknownReason. Their erosionUnits, erosionRate and impactShare are null — rendered as —, never 0. |
clusterValue · baselineValue | The KPI over this cluster’s cases, and over the whole agent — the two numbers whose gap is the erosion. |
erosionRate | The per-case gap vs baseline, oriented by the KPI’s direction. For containment above, 0.2898 is a 29-point drop. |
erosionUnits | The gap expressed in cases — the “lost” contained (or cheap) cases the cluster is responsible for. This is what the ranking and the shares are computed from. |
impactShare | This cluster’s 0..1 fraction of the KPI’s total measured erosion. The shares of the ranked clusters sum toward 1. null when it couldn’t be formed — never 0. |
distinguishable | Whether the sample can tell this cluster’s rate apart from the baseline. false means the gap is inside sampling noise; a thin cluster reads false and sits in unknown. |
clusterDecided · baselineDecided | The denominators behind each side. A 90% rate over 7 decided cases is not the fact a 90% rate over 700 is. |
computedAt | When this verdict was last recomputed. |
A null renders as ”—”, and it always means “we could not measure this”, never “zero”. A
cluster in the unknown list, a KPI that can’t be attributed, an impact share that couldn’t be
formed — all read as a dash, the same honesty rule the KPI tiles follow.
Fixes that moved this KPI
What this list is, and why unmeasurable movers are listed apart, is in the guide.
Read it over the API
movedByFixes and movedByFixesUnknown ride on GET /kpis/{id}, beside the KPI’s
value. Read access is enough.
curl -sf "$NEENS_BASE_URL/api/kpis/$KPI_ID" \
-H "Authorization: Bearer $NEENS_API_KEY"{
"kpi": {"id": "a68e0cbb059f4b0ba38f409ee2145b19", "measureKey": "containment_rate",
"label": "Self-serve containment", "…": "…"},
"value": 0.8731,
"…": "…",
"movedByFixes": [
{
"closeoutId": "co-9f2a1c",
"remediationId": "rem-4471",
"remediationTitle": "Guardrail over-blocks refunds",
"prUrl": "https://github.com/acme/agent/pull/42",
"status": "improved",
"delta": 0.19, "deltaPct": 30.65, "distinguishable": true,
"before": 0.62, "after": 0.81,
"mergedAt": "2026-07-10T00:00:00Z", "verdictAt": "2026-07-17T02:11:00Z",
"clusterId": "cl_7f21a3"
},
{
"closeoutId": "co-71bd08",
"remediationId": "rem-4390",
"remediationTitle": "Order-lookup tool retries on timeout",
"prUrl": "https://github.com/acme/agent/pull/38",
"status": "flat",
"delta": 0.01, "deltaPct": 1.16, "distinguishable": false,
"before": 0.86, "after": 0.87,
"mergedAt": "2026-07-02T00:00:00Z", "verdictAt": "2026-07-09T02:07:00Z",
"clusterId": "cl_3b90e2"
}
],
"movedByFixesUnknown": [
{
"closeoutId": "co-55c0a2",
"remediationId": "rem-4210",
"remediationTitle": "Escalation-label taxonomy revised",
"prUrl": "https://github.com/acme/agent/pull/31",
"status": "unknown",
"delta": null, "deltaPct": null, "distinguishable": null,
"before": null, "after": null,
"mergedAt": "2026-06-20T00:00:00Z", "verdictAt": "2026-06-27T02:04:00Z",
"clusterId": "cl_c14af8"
}
]
}| Field | What it tells you |
|---|---|
closeoutId · remediationId · remediationTitle · prUrl | Which fix — and a link straight to the merged PR. |
status | improved · flat · regressed · unknown, oriented by the KPI’s direction. Every unknown sits in movedByFixesUnknown, not here. |
before · after | The KPI before the deploy and after it. null (→ —) for an unmeasurable mover. |
delta · deltaPct | The move, in the KPI’s own unit and as a percentage. null when there is no honest delta to draw. |
distinguishable | Whether the move cleared sampling noise. false means it moved, but inside the noise — the list says so rather than celebrate it. null for a cost KPI, which carries no interval. |
mergedAt · verdictAt | When the fix merged, and when its close-out settled. The list is ordered by newest mergedAt first. |
clusterId | The failure cluster the fix targeted — the corner of your traffic whose recovery this move reflects. |
A null here is a dash, and means “we could not measure this move”, never “zero”. A mover in
movedByFixesUnknown, a delta that couldn’t be formed across a definition change — both render as
—, the same honesty rule the KPI tiles and the
eroding-clusters list follow.
Define a KPI
The Definitions tab walkthrough is in the guide.
Do it over the API
Everything the tab does is available over REST, so CI or your infrastructure-as-code can set it. Read the effective definition with an agent API key; change it with an admin credential.
export NEENS_BASE_URL="https://your-neens-host" # your Neens origin
export NEENS_API_KEY="nk_live_..." # your agent API key
# Read the effective definition (any read access)
curl -sf "$NEENS_BASE_URL/api/kpi-definitions" -H "Authorization: Bearer $NEENS_API_KEY"
# Discover what you can pick from — the catalogue annotated with what THIS agent has observed
curl -sf "$NEENS_BASE_URL/api/kpi-definitions/options" -H "Authorization: Bearer $NEENS_API_KEY"Declaring a definition is a full replacement, sent by an admin session — omit a KPI to revert it to the platform default. A partial merge is how half a definition produces a number nobody intended, so Neens doesn’t do one.
curl -sf -X PUT "$NEENS_BASE_URL/api/kpi-definitions" \
-H "Authorization: Bearer $SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"definitions": {
"containment": {
"mode": "outcome",
"escalationKind": "escalation", "escalationValue": true,
"containmentKind": "containment", "containmentValue": true
},
"resolution_time": {
"mode": "outcome",
"resolutionKind": "resolution_time",
"resolutionTimeMode": "measured_duration"
}
}
}'resolution_time.resolutionKind names the kind that carries the duration, which is usually not
the same kind as the boolean that says a case was resolved. Point it at a duration kind
(resolution_time, handle_time); pointed at a boolean, the measure reports unavailable and names
the mistake rather than reading true as a one-millisecond case.
A definition is validated at write time, so a malformed one is a 422 on the PUT and never a
silently-wrong number on a dashboard later.
Promote a measure to a KPI
What a KPI is, and the fields it carries, are in the guide.
Find out what you can promote
GET /kpis/options returns the measures this agent may promote, each annotated with two things
you want to know before you commit: whether the agent can actually get a number out of it
today (ready), and whether it is already promoted.
curl -sf "$NEENS_BASE_URL/api/kpis/options" \
-H "Authorization: Bearer $NEENS_API_KEY"{
"projectId": "proj_x",
"measures": [
{
"key": "containment_rate",
"label": "Containment rate",
"unit": "ratio",
"agg": "rate",
"category": "business",
"grain": "case",
"isCustomMeasure": false,
"provenance": null,
"defaultDirection": "higher_is_better",
"promoted": false,
"promotedKpiId": null,
"ready": true,
"unavailable": null
},
{
"key": "cost_per_case",
"label": "Cost per case",
"unit": "usd",
"agg": "ratio",
"category": "business",
"grain": "case",
"isCustomMeasure": false,
"provenance": null,
"defaultDirection": "lower_is_better",
"promoted": true,
"promotedKpiId": "3d81f0a742be4c1e9a05d6b3f81c47ab",
"ready": true,
"unavailable": null
},
{
"key": "custom:deflection-rate",
"label": "Deflection rate",
"unit": "ratio",
"agg": "rate",
"category": "business",
"grain": "case",
"isCustomMeasure": true,
"provenance": "measured",
"defaultDirection": "higher_is_better",
"promoted": false,
"promotedKpiId": null,
"ready": false,
"unavailable": {"reason": "no_matching_outcomes"}
}
],
"directions": ["higher_is_better", "lower_is_better"],
"statuses": ["draft", "active", "archived"],
"reviewCadences": ["none", "weekly", "monthly", "quarterly"],
"max": 24,
"remaining": 21
}The list is ordered so the top of it is something you can promote right now: ready measures first, then ones already promoted, then alphabetically.
| Annotation | What it means |
|---|---|
ready | Whether this agent can get a number out of the measure today. |
unavailable | null when ready, otherwise {"reason": "<code>"} — definition_not_configured (the measure’s meaning hasn’t been declared yet), no_matching_outcomes or no_matching_scores (the signal the measure reads has never arrived in this agent). |
promoted / promotedKpiId | Whether a live KPI already exists on this measure, and which one — what keeps you from creating a second commitment on the same number. |
defaultDirection | The proposal you can accept or override. |
provenance | For a custom measure, the badge it will carry (Measured / Inferred). null for a platform measure, which decides its own. |
max / remaining | Your agent’s cap and what’s left of it. null means no cap. Read them rather than assuming a figure. |
ready: false does not stop you promoting the measure — committing to a number you are about
to start collecting is perfectly reasonable. It tells you the tile will read blank today, so the
blank is expected rather than alarming, and it names the page that fixes it.
Create one
Pick the measure
From GET /kpis/options above. Take defaultDirection with it: you only need to send direction
when you disagree with the proposal.
POST the commitment
Only measureKey and label are required. Everything else has a default, and every default is the
conservative one — draft, no target, priority 100, no review cadence.
curl -sf -X POST "$NEENS_BASE_URL/api/kpis" \
-H "Authorization: Bearer $SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"measureKey": "containment_rate",
"label": "Self-serve containment",
"description": "Share of support cases the assistant closes without a human. Board metric for FY27.",
"direction": "higher_is_better",
"target": 0.85,
"owner": "Support Platform (@rota-support)",
"status": "active",
"priority": 10,
"reviewCadence": "monthly"
}'{
"kpi": {
"id": "a68e0cbb059f4b0ba38f409ee2145b19",
"projectId": "proj_x",
"measureKey": "containment_rate",
"label": "Self-serve containment",
"description": "Share of support cases the assistant closes without a human. Board metric for FY27.",
"direction": "higher_is_better",
"target": 0.85,
"owner": "Support Platform (@rota-support)",
"status": "active",
"priority": 10,
"reviewCadence": "monthly",
"isCustomMeasure": false,
"createdAt": "2026-08-14T10:22:41+00:00",
"createdBy": "[email protected]",
"updatedAt": "2026-08-14T10:22:41+00:00",
"updatedBy": "[email protected]",
"archivedAt": null
}
}The response is a 201. A KPI created without status stays a draft: it is authored, it is not
yet a promise, and it does not appear on the summary.
Commit to a figure when you’re ready
A KPI with "target": null is a legitimate, common state — we watch this number, we have not
committed to a figure. Promote first, argue about the number later:
KPI_ID=a68e0cbb059f4b0ba38f409ee2145b19
curl -sf -X PATCH "$NEENS_BASE_URL/api/kpis/$KPI_ID" \
-H "Authorization: Bearer $SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{"target": 0.88, "status": "active", "reviewCadence": "quarterly"}'Read the current value
GET /kpis/{id} returns the KPI and the number, resolved over the range you ask for
(?range=30d).
curl -sf "$NEENS_BASE_URL/api/kpis/$KPI_ID?range=30d" \
-H "Authorization: Bearer $NEENS_API_KEY"{
"kpi": {"id": "a68e0cbb059f4b0ba38f409ee2145b19", "measureKey": "containment_rate",
"label": "Self-serve containment", "direction": "higher_is_better",
"target": 0.85, "status": "active", "…": "…"},
"value": 0.8731,
"unit": "ratio",
"provenance": "measured",
"coverage": {"cases": 812, "decided": 704, "rate": 0.867, "invalid": 0, "asOf": "2026-08-14T11:04:18Z"},
"definitionVersion": "02f3318341c0df32",
"pricing": null,
"unavailable": null,
"targetStatus": "met",
"trend": {"direction": "improved", "current": 0.8731, "previous": 0.8104,
"delta": 0.0627, "pctDelta": 7.73692, "fromDay": "2026-08-06",
"toDay": "2026-08-13", "lookbackDays": 7, "unavailable": null},
"trendUnavailable": null,
"range": "30d",
"asOf": "2026-08-14T11:04:18Z"
}Everything below value is there for the same reason the rest of this page exists: a number without
its evidence is not a number you can act on.
| Field | What it tells you |
|---|---|
value | The measure’s current value over range. null when it could not be computed — never 0. |
unit | The measure’s unit (ratio, ms, usd, count, …) — the unit target is in, too. |
provenance | Measured or Inferred, exactly as on a widget. Promoting a measure does not upgrade its evidence. |
coverage | The block documented under Coverage: how many cases the window held, and how many the definition could decide. null when the measure produced no coverage to report. |
definitionVersion | The fingerprint of the KPI definition this number was computed under. If it changed since you last looked, the meaning of “escalated” changed — and so did the number, for reasons that have nothing to do with the agent. |
pricing | null unless the measure is priced. On a cost measure it carries the pricing detail for the window — see the callout below. |
unavailable | null when the value came out. Otherwise {"reason": …}, naming why the value could not be computed. |
targetStatus | met · missed · unknown. See below. |
trend | Direction of travel, read off recorded history — improved, flat or regressed, plus the two days and values behind it. null when there is no drawable line. |
trendUnavailable | Why there is no trend: no_snapshots, insufficient_history or definition_changed. Separate from unavailable, because “we have no history” and “we have no value” are different facts and a tile has to be able to show one without the other. |
range · asOf | The window, and when this was computed. Non-negotiable on a business number — see late arrival. |
movedByFixes · movedByFixesUnknown | The shipped fixes that moved this KPI — merged remediations whose close-out registered a move on this number, with the unmeasurable ones listed apart. |
A non-null pricing block means the figure is a floor, not a value. When some of the models in
the window have no price, their tokens are counted and their dollars are not, so a cost_per_case
KPI reads lower than the truth. The block is what lets the number be badged partial instead of
quietly under-reporting spend — and a target judged met against an under-reported cost is exactly
the wrong answer to get silently. Fill the gaps under Settings → Model pricing; see
Cost & model pricing.
Met, missed, and the answer most dashboards get wrong
targetStatus compares value against target in the KPI’s declared direction, inclusively —
hitting the target exactly is meeting it, in both directions.
direction | met when | missed when |
|---|---|---|
higher_is_better | value >= target | value < target |
lower_is_better | value <= target | value > target |
No value means unknown. It never means missed. A KPI whose window produced no decided cases
has not been missed — nobody knows whether it was. Rendering a red missed tile for absent data is
how a dashboard manufactures a crisis for an agent that simply hasn’t sent outcomes yet, and it is
the same mistake as counting an unmeasured case as contained. When value is null, targetStatus
is unknown and unavailable.reason says why in a sentence you can act on.
A null target is not a target of zero. “We watch this number but haven’t committed to a
figure” is a real state — it is what every KPI looks like on day one. It also reads unknown: there
is nothing to have met or missed. A lower_is_better KPI with a null target is not silently
holding you to 0.
So a KPI with no data this window looks like this — and says why, in the same breath:
{
"kpi": {"id": "a68e0cbb059f4b0ba38f409ee2145b19", "label": "Containment rate",
"direction": "higher_is_better", "target": 0.72, "status": "active", "…": "…"},
"value": null,
"unit": "ratio",
"provenance": "measured",
"coverage": null,
"definitionVersion": "02f3318341c0df32",
"pricing": null,
"unavailable": {"reason": "Business outcomes are not available in this workspace yet, so containment cannot be measured."},
"targetStatus": "unknown",
"trend": null,
"trendUnavailable": {"reason": "no_snapshots"},
"range": "30d",
"asOf": "2026-08-19T04:49:16.109253+00:00"
}Note the shape: unavailable is flat, and its reason is usually a readable sentence rather
than a code — it is written to be shown to the person looking at the tile, so treat it as prose and
don’t switch on it. Three values are stable codes worth branching on: measure_removed (the measure behind the KPI was deleted), measure_redefined (a measure with this KPI’s key exists again, but it is not the one that was promoted — see below) and no_data_in_window (the measure resolved, but the window you asked for selected nothing). Everything else is prose that can be reworded, so match on those three and render the rest verbatim.
Three more honesty rules ride on that block:
trendisnullwhenever there is no drawable line, andtrendUnavailable.reasonsays which of the three reasons it is — hereno_snapshots, because an agent with no outcomes has no recorded days either. Neens does not draw a sparkline it cannot back with recorded history, and it never interpolates one across a definition change. A missing trend never suppresses the value: the two live in separate fields precisely so a KPI can show today’s number while saying it cannot yet show the direction of travel.- If the custom measure behind a KPI is deleted,
unavailable.reasonis the literalmeasure_removedrather than a number. The commitment survives the measure — you can see that the promise is now pointing at nothing, which is a fixable situation, instead of reading a wrong figure that looks fine. See before you delete a shared measure. coverageandasOfcome with every value, always. Outcomes arrive late by design, so re-reading the same window tomorrow can legitimately show higher coverage and a different value. Nothing changed retroactively; more evidence arrived about the same cases.
Every active KPI at once
GET /kpis/summary is the scorecard: every active KPI with its value, in display order (live
before retired, then priority ascending, then label — deterministic, so two identical reads never
render in two different orders).
curl -sf "$NEENS_BASE_URL/api/kpis/summary?range=30d" \
-H "Authorization: Bearer $NEENS_API_KEY"{
"projectId": "proj_x",
"range": "30d",
"version": "a17be0c94d2f5561",
"asOf": "2026-08-14T11:04:18Z",
"count": 2,
"truncated": false,
"kpis": [
{
"kpi": {"id": "a68e0cbb059f4b0ba38f409ee2145b19", "measureKey": "containment_rate",
"label": "Self-serve containment", "direction": "higher_is_better",
"target": 0.85, "priority": 10, "…": "…"},
"value": 0.8731, "unit": "ratio", "provenance": "measured",
"coverage": {"cases": 812, "decided": 704, "rate": 0.867, "invalid": 0, "asOf": "2026-08-14T11:04:18Z"},
"definitionVersion": "02f3318341c0df32", "pricing": null, "unavailable": null,
"targetStatus": "met",
"trend": {"direction": "improved", "current": 0.8731, "previous": 0.8104,
"delta": 0.0627, "pctDelta": 7.73692, "fromDay": "2026-08-06",
"toDay": "2026-08-13", "lookbackDays": 7, "unavailable": null},
"trendUnavailable": null,
"range": "30d", "asOf": "2026-08-14T11:04:18Z"
},
{
"kpi": {"id": "3d81f0a742be4c1e9a05d6b3f81c47ab", "measureKey": "cost_per_case",
"label": "Cost to serve", "direction": "lower_is_better",
"target": 0.42, "priority": 20, "…": "…"},
"value": 0.5108, "unit": "usd", "provenance": "measured",
"coverage": {"cases": 812, "decided": 812, "rate": 1.0, "invalid": 0, "asOf": "2026-08-14T11:04:18Z"},
"definitionVersion": null, "pricing": {"…": "the pricing detail behind the dollars"},
"unavailable": null,
"targetStatus": "missed",
"trend": null, "trendUnavailable": {"reason": "insufficient_history"},
"range": "30d", "asOf": "2026-08-14T11:04:18Z"
}
]
}Each entry is exactly the GET /kpis/{id} block above — same fields, same rules — so a scorecard
row and a detail view can’t tell you different things about the same commitment.
version is a content fingerprint of the KPIs themselves. It changes when a commitment changes —
a new target, a flipped direction, an archive — and not when somebody re-saves a KPI without
changing anything, so a cached scorecard is not invalidated by a no-op edit.
count is how many rows came back, and truncated is the one you should never see: it is true
when the per-agent cap was lowered underneath data that
already existed, so the tail of the list was dropped. It is reported rather than hidden, because a
silently shortened scorecard reads as “these are all your KPIs” — which would be a lie about a
promise somebody made. Archive down to the cap and it goes back to false.
Drafts and archived KPIs are excluded here. To see them, list with an explicit status:
curl -sf "$NEENS_BASE_URL/api/kpis?status=all" \
-H "Authorization: Bearer $NEENS_API_KEY"{"projectId": "proj_x", "kpis": ["…"], "version": "a17be0c94d2f5561", "count": 3, "max": 24}GET /kpis without a status returns everything except archived. status=active, draft,
archived and all narrow or widen it.
Changing one
PATCH /kpis/{id} is a genuine partial update: only the keys you send are touched, so
{"target": 0.9} cannot blank the description you didn’t mention. An explicit null is
meaningful — it withdraws a committed target, or clears a free-text field — so it is the absence
of a key, not its null value, that means “leave this alone”.
measureKey cannot be changed. Repointing a KPI at a different measure is a 422, not a silent
success. Every review note, screenshot and conversation that cites “Self-serve containment” would
silently start describing a different number, retroactively. Promote the other measure as its own
KPI and archive this one — that leaves an honest record of what was promised and when it changed.
{"detail": "measure_key cannot be changed on an existing KPI. History that already cites this KPI would silently change meaning. Create a new KPI instead."}Retiring one
curl -sf -X POST "$NEENS_BASE_URL/api/kpis/3d81f0a742be4c1e9a05d6b3f81c47ab/archive" \
-H "Authorization: Bearer $SESSION_TOKEN"Archiving retires the commitment without deleting it. An archived KPI keeps its history — the
history of what a team promised is most of the point of having promised it — is excluded from
GET /kpis/summary and from the default GET /kpis listing, stops counting against the
one-per-measure rule, and stamps archivedAt. Nothing about the underlying measure changes: the
widget on your dashboard keeps working, because a KPI never owned that number in the first place.
One live KPI per measure, and the cap
Two limits, both enforced loudly:
-
One live KPI per measure per agent. Two simultaneous commitments to
containment_ratemeans two targets, and the honest answer to “did we hit it?” becomes “which one?”. Creating a duplicate is a409naming the KPI that already exists, so you can go and edit it:{"detail": "This project already has a KPI on 'containment_rate' ('Self-serve containment', id a68e0cbb059f4b0ba38f409ee2145b19). Edit it, or archive it first."}Archived KPIs are exempt. You can re-commit to a measure you retired last quarter — the old commitment stays in the record and the new one starts clean.
-
A per-agent cap on KPIs. Reaching it is a
409naming the limit, never a silent drop:{"detail": "This project already has 12 KPIs, the maximum is 12. Archive one you no longer commit to, then try again."}Read the live figures from
max/remainingonGET /kpis/options(ormaxonGET /kpis) rather than hard-coding one. The cap exists because a scorecard with sixty rows is not a scorecard — it is a table nobody reads, and every entry on it stops meaning “we promised this”.
KPI endpoints and vocabularies, in full
| Endpoint | What it does |
|---|---|
GET /kpis | List. ?status=active|draft|archived|all; the default is everything except archived. Returns {projectId, kpis, version, count, max}. |
GET /kpis/options | The promotable measures, each with ready / unavailable and promoted / promotedKpiId, plus the vocabularies and max/remaining. |
POST /kpis | Create. 201 {kpi}. 409 on a duplicate measure or the cap; 422 on a bad field. |
GET /kpis/{id} | The KPI plus its current value and the fixes that moved it (movedByFixes / movedByFixesUnknown). ?range=30d. |
PATCH /kpis/{id} | Partial update. 422 on measureKey or an unknown field. |
POST /kpis/{id}/archive | Retire the commitment; keeps the history. |
GET /kpis/summary | Every active KPI with its value, in display order. ?range=30d. Returns {projectId, range, version, asOf, count, truncated, kpis}, where each entry is the GET /kpis/{id} block. |
GET /kpis/{id}/eroding-clusters | The failure clusters eroding this KPI: ranked (worst first, each with impactShare) and unknown (thin clusters, with a reason), plus attributable — false for a percentile KPI. |
GET /kpis/{id}/history | The recorded days for one KPI, oldest first, plus trend, breaks and definitionChangedAt. ?days=30, clamped; 422 on days=0. |
POST /kpis/{id}/snapshot | Record one KPI’s recent days now. ?days= to redo a specific stretch. Idempotent. |
POST /kpis/snapshot | The same for every active KPI in the agent. |
Every route is also reachable at its bare path (/kpis) as well as under /api.
| Vocabulary | Values |
|---|---|
direction | higher_is_better · lower_is_better |
status | draft · active · archived |
reviewCadence | none · weekly · monthly · quarterly |
targetStatus | met · missed · unknown |
priority | Integer 1–999, lower sorts first. Default 100. |
unavailable.reason (value) | Free text meant for a reader — not a closed enum. measure_removed, measure_redefined and no_data_in_window are stable codes; anything else is prose to render as-is. |
unavailable.reason (options) | definition_not_configured · no_matching_outcomes · no_matching_scores |
trendUnavailable.reason | no_snapshots · insufficient_history · definition_changed |
trend.direction | improved · flat · regressed · unknown |
History and trends
How recorded history and trends work is in the guide.
A day with no number is recorded as a day with no number
A recorded day whose measure produced nothing is written down anyway, with value: null and an
unavailableReason. It is not skipped, and it is not a zero.
That matters because “we looked on the 12th and there was nothing” is history, and it is a different statement from “we never looked” and from “the measure was deleted”. Collapsing all three into a gap in the series throws away the only evidence that distinguishes them.
Two consequences you’ll see directly:
- Every point carries its own
targetStatus, and a point with no value readsunknown— nevermissed. A day with no decided cases has not broken the commitment; nobody knows whether it did. This is the same rule the live tile follows, and it is why a history chart never paints absent days red. - A trend steps over an empty day rather than stopping at it. The comparison uses the newest day that has a value, so a quiet Sunday in the middle of the window does not erase the trend of the days around it.
Recent days get corrected as outcomes arrive
A recorded day is not written once and frozen. Outcomes arrive late — a ticket closed on Thursday decides a case that started on Monday — so what Monday’s containment was keeps changing for a few days after Monday ends. Neens therefore re-checks the most recent days on every pass and rewrites them when the answer changed.
Three fields on each point tell you exactly what happened to it:
| Field | Meaning |
|---|---|
computedAt | When this day was first recorded. It never moves again. |
asOf | When it was last re-checked. This moves on every pass, whether or not anything changed. |
revisions | How many times the recorded fact actually changed — the value, the reason it was absent, or the definition behind it. Re-reading a day forty times is not forty revisions, and a counter that said so would be worth nothing. |
So revisions: 0 with a recent asOf means we keep checking, and the number keeps coming out the
same — which is a stronger statement than a number nobody re-examined. Days far enough in the past
stop being re-checked at all; their asOf stops moving, and that is the number settling, not the
recording stopping.
Re-recording a day rewrites it — it never appends. Each (agent, KPI, day) is one row, so
calling POST /kpis/snapshot five times in a row leaves the history exactly as it was after the
first call, with a newer asOf and the same revisions. Re-running a window is always safe.
Read a KPI’s history
curl -sf "$NEENS_BASE_URL/api/kpis/$KPI_ID/history?days=30" \
-H "Authorization: Bearer $NEENS_API_KEY"{
"kpiId": "a68e0cbb059f4b0ba38f409ee2145b19",
"measureKey": "containment_rate",
"unit": "ratio",
"direction": "higher_is_better",
"target": 0.72,
"days": 30,
"points": [
"…",
{
"id": "09ed0073f8232afc9ba68bc00669fccc74484dea",
"projectId": "proj_x",
"kpiId": "a68e0cbb059f4b0ba38f409ee2145b19",
"measureKey": "containment_rate",
"day": "2026-08-11",
"value": 0.3333333333333333,
"unit": "ratio",
"provenance": "measured",
"coverage": {"cases": 3, "decided": 3, "rate": 1.0, "invalid": 0, "asOf": "2026-08-18T02:14:07.540138+00:00"},
"unavailableReason": null,
"unavailable": null,
"definitionFingerprint": "a8bb2e3b1a48b2d8",
"measureVersions": {"kpiDefs": "1:2026-08-02T09:12:44+00:00", "customMeasures": "0:", "priceTable": "3:2026-07-30T00:00:00+00:00"},
"asOf": "2026-08-18T02:14:07.540138+00:00",
"computedAt": "2026-08-12T02:11:52.771904+00:00",
"revisions": 1,
"comparableWithPrevious": true,
"targetStatus": "missed"
},
{
"id": "7804621f6b59d526c878a7279b9a81d611242021",
"day": "2026-08-12",
"value": null,
"unit": "ratio",
"provenance": "measured",
"coverage": {"cases": 0, "decided": 0, "rate": null, "invalid": 0, "asOf": "2026-08-18T02:14:07.541940+00:00"},
"unavailableReason": "no_data_in_window",
"unavailable": {"reason": "no_data_in_window"},
"definitionFingerprint": "a8bb2e3b1a48b2d8",
"asOf": "2026-08-18T02:14:07.540138+00:00",
"computedAt": "2026-08-13T02:10:31.118442+00:00",
"revisions": 0,
"comparableWithPrevious": true,
"targetStatus": "unknown",
"…": "…"
},
"…",
{
"id": "fa740ee4144e8d007a887f42d1357baac7cd7d37",
"day": "2026-08-18",
"value": 0.75,
"unit": "ratio",
"provenance": "measured",
"coverage": {"cases": 4, "decided": 4, "rate": 1.0, "invalid": 0, "asOf": "2026-08-19T02:12:19.985752+00:00"},
"unavailableReason": null,
"unavailable": null,
"definitionFingerprint": "a8bb2e3b1a48b2d8",
"asOf": "2026-08-19T02:12:19.985752+00:00",
"computedAt": "2026-08-19T02:12:19.985752+00:00",
"revisions": 0,
"comparableWithPrevious": true,
"targetStatus": "met",
"…": "…"
}
],
"breaks": [],
"definitionChangedAt": null,
"trend": {
"direction": "improved",
"current": 0.75,
"previous": 0.3333333333333333,
"delta": 0.4166666666666667,
"pctDelta": 125.00000000000003,
"fromDay": "2026-08-11",
"toDay": "2026-08-18",
"lookbackDays": 7,
"unavailable": null
},
"truncated": false
}| Field | What it tells you |
|---|---|
days | The window actually served, in complete days. Ask for more than the workspace allows (a year, unless yours is set lower) and this is the number you got — with truncated: true beside it, so a clamp never passes for “that’s all the history there is”. |
points | The recorded days, oldest first, one per calendar day. A day that was never recorded simply isn’t there; a day recorded with no number is there with value: null. |
unit · direction · target | The KPI’s own vocabulary, echoed so a chart can label and threshold the series without a second call. |
breaks · definitionChangedAt | Where the meaning of the number changed. Empty and null when it never did. |
trend | The trend block — always present here, unknown included. |
And per point:
| Field | What it tells you |
|---|---|
day | The complete UTC day this covers. |
value | What the measure came out as that day. null when it produced nothing — never 0. |
unavailableReason · unavailable | Why there is no value. The same vocabulary the live tile uses, so a point and a tile never give two accounts of one absence. |
targetStatus | met · missed · unknown, judged for that day against the KPI’s current target. unknown whenever the day has no value, or the KPI has no target. |
coverage | How many cases that day held, and how many the definition could decide — the same block as on a live read. A 90% containment over four decided cases is not the same fact as 90% over four hundred. |
provenance | measured or inferred, for that day. Promoting a measure never upgrades its evidence, and neither does recording it. |
definitionFingerprint · comparableWithPrevious | The definition behind the number, and whether this point may be compared with the one before it. |
measureVersions | Which versions of your definitions, custom measures and price table were in force. It is what makes a past number explainable a quarter later. |
asOf · computedAt · revisions | When it was last re-checked, when it was first recorded, and how many times the fact actually changed. |
id · projectId · kpiId · measureKey | Identity. The id is derived from the agent, the KPI and the day, which is why re-recording a day rewrites it instead of appending. |
?days must be a positive integer — days=0 is a 422, not an empty chart:
{"detail": "days must be a positive integer."}Record history now
Both routes run the same recording pass Neens runs for you, so a manual catch-up and the automatic one can never disagree. Recording needs the same authority as any other change to a KPI (admin); reading history needs only read access.
curl -sf -X POST "$NEENS_BASE_URL/api/kpis/$KPI_ID/snapshot?days=14" \
-H "Authorization: Bearer $SESSION_TOKEN"{"projectId": "proj_x", "kpis": 1, "days": 14, "computed": 14, "written": 14,
"revised": 0, "skipped": 0, "errors": 0, "truncated": false}| Field | What it tells you |
|---|---|
kpis | How many active KPIs were in scope. Drafts and archived KPIs are not recorded — a draft is not a commitment, and an archived one is over. |
days | How many distinct days the pass covered. |
computed · written | (KPI, day) pairs measured, and rows written. |
revised | Rows whose stored fact actually changed — the late-outcome corrections described above. |
skipped · truncated | What a large catch-up left for the next pass. truncated: true means the backlog was bigger than one pass, and the oldest, stalest days went first; call it again to keep draining. A cap that said nothing would read as “everything is covered”. |
errors | KPIs that could not be recorded. One bad KPI never aborts the rest. |
Omit days and each KPI gets the window it needs: the last few days for one that already has
history (enough to absorb late outcomes), and a one-time backfill of the recent past for one being
recorded for the first time. Pass days explicitly when you need a specific stretch redone —
after correcting a definition, or after a late bulk import of outcomes. It must be between 1 and
the workspace’s ceiling:
{"detail": "days must be between 1 and 365."}Backfill only reaches as far as your data does. Recording a day is a measurement of that day,
not a reconstruction of it: days before your outcomes started arriving are recorded honestly as days
with no number and an unavailableReason, which is the correct answer and a much better one than a
flat line at zero.
Troubleshooting the API
For symptoms on the page itself, see the guide’s troubleshooting.
| Symptom | Cause | Fix |
|---|---|---|
A KPI reads targetStatus: "unknown" | Either the measure produced no value this window, or the KPI has no target yet. Both are unknown on purpose — never missed. | Read unavailable on the response: it names which one. If it’s coverage, the fix is upstream; if it’s the target, PATCH a figure onto it. |
A KPI reads measure_removed | The custom measure it was promoted from has been deleted. | Re-create the measure, or archive the KPI. The commitment is not silently reporting a wrong number in the meantime. |
A KPI reads measure_redefined | A custom measure with the same key exists, but it is not the one this KPI was promoted from — the slug was freed by a delete and then reused for a measure that means something else. | Restore the original definition and the KPI resumes scoring by itself, or archive the KPI and promote the new measure deliberately. A target agreed for one measure is never scored against a different one. |
trend is null with no_snapshots | Nothing has been recorded for this KPI yet — it was promoted a moment ago, or it is still a draft. | Set it active and wait for the next recording pass, or POST /kpis/{id}/snapshot to record it now. |
trend is null with insufficient_history | Fewer than two comparable days a week apart carry a value. | Wait. A KPI promoted on Monday gets its first trend the following Monday; POST /kpis/{id}/snapshot?days=… backfills what your data can support. |
trend is null with definition_changed | The definition behind the number changed inside the window, so the two ends measure different things. | Nothing is broken — breaks on GET /kpis/{id}/history names the day. Re-record the window if the change was a correction rather than a redefinition. |
A day in history shows value: null | That day produced no number — no cases, no decided cases, or the measure could not resolve. | Read unavailableReason on the point. It is recorded deliberately: targetStatus is unknown, never missed. |
Yesterday is the newest day in history | Only completed UTC days are recorded — today is still filling up. | Nothing to fix. Use GET /kpis/{id} for today’s live figure. |
| A recorded day’s value changed | Late outcomes landed and the recent window was re-recorded. revisions went up; computedAt did not move. | Nothing to fix — that is the history catching up with the evidence. |
GET /kpis/{id}/history says truncated: true | You asked for more days than the workspace serves in one call. | Read days on the response for what you actually got. |
POST /kpis returns 409 | Either that measure already has a live KPI, or the agent is at its cap. | The message names which, and which KPI. Edit or archive the existing one. Archived KPIs don’t block a re-commit. |
GET /kpis/summary says truncated: true | There are more active KPIs than the agent’s cap allows — the cap was lowered under existing data, and the tail of the list was dropped. | Archive down to the cap. The flag is there so a shortened scorecard never passes for a complete one. |
A cost KPI reads met but the figure looks low | The window is only partly priced, so the value is a floor — pricing on the response is non-null. | Price the missing models under Settings → Model pricing, then re-read. |
POST /kpis returns 422 on target | A ratio target was typed as a percentage (85 instead of 0.85), or a target went negative on a count/duration/currency measure. | Send the figure in the measure’s own unit — the unit field on GET /kpis/{id} and GET /kpis/options tells you which. |