Custom measures
Business KPIs ships three named numbers: containment rate, resolution time and cost per case. Three named KPIs are enough to prove the idea. They are not enough to describe your business, because “containment” is not one thing:
| Your team | What “handled it” actually means |
|---|---|
| Support | Resolved without a human ever touching it |
| Sales | Qualified without an SDR call |
| Internal IT | The ticket was never created in the first place |
| Collections | Payment arranged without a callback |
A predicate hard-coded for the first row is silently wrong for the other three. So instead of adding a fourth, Neens lets you declare a measure of your own — your label, your source, your grain, your target — and then treats it exactly like one of ours: it appears in the measure gallery, charts on a dashboard, backs an alert rule, and carries its provenance and coverage everywhere it renders.
A custom measure is a selection, not a query language. You pick a source Neens already stores, a grain, and an aggregation. There is no SQL box, no expression field, and no way for a definition to reach data outside your own tenant. That is deliberate: the point is a KPI your whole company can trust, not a reporting tool one person can break.
At a glance
| Where | The Custom measures tab of the Business KPIs page |
| Who | Admins (MANAGE_MEASURES); Silver plan and above |
| Scope | Company-wide — one definition, shared by every agent. See Scope |
| Reads | Anyone with READ — a viewer can see what a number means |
| Sources | A business outcome, a trace metadata field, or a classifier/judge metric |
| Grains | One trace, or one case (a conversation — many traces) |
| Aggregations | count, sum, avg, p50, p90, p99, rate |
| Appears in | Measure gallery · dashboard widgets · alert rules |
| API | /custom-measures |
The three sources, and why the badge matters
Every measure reads from exactly one of three places, and the source decides the provenance badge — you do not get to choose it. A number from your helpdesk and a number an LLM guessed from a transcript are different kinds of claim, and someone reading a dashboard six months from now has no other way to tell them apart.
| Source | Reads | Badge | What it means |
|---|---|---|---|
| Business outcome | A business outcome kind | Measured | Your system of record recorded this. The strongest claim available. |
| Trace metadata | A field in the trace’s metadata | Emitted | Your agent asserted this about itself. Useful, but nothing confirmed it. |
| Classifier / judge | A score metric + its labels | Inferred | A model read the transcript and decided. An opinion, not a fact. |
Emitted is not measured. If your agent writes resolved: true into trace metadata, that is
the agent’s opinion of its own work — the same agent whose failures you are trying to find. It is
a perfectly reasonable thing to measure, and it is badged Emitted so nobody mistakes it for the
customer’s own confirmation.
A custom measure carries three of the four provenance tiers — Measured, Emitted and
Inferred — one per source above. The fourth tier, Derived (Neens computing a number from
your traces with a deterministic rule, like cost_per_case), belongs to the platform KPIs and isn’t
a source you pick here. See the four provenance tiers.
Define your first measure
Open the Custom measures tab
Admin-only. On the Business KPIs page, open the Custom measures tab. If it’s read-only, either you are not an admin or your plan does not include custom measures (Silver and above). Everyone can still read the measures your admins define.
What you define here is company-wide: every agent sees it, and so does every later edit or deletion. Scope explains what that means before you change one.
Name it in your own words
The name is what appears on every chart, alert and digest — write it the way your team says it out loud (“Deflection rate”, “Happy customers”, “Time to first useful answer”).
The identifier becomes the measure key (custom:deflection-rate) and is fixed once
created, because alert rules and dashboard widgets store that key. Renaming the measure later
is free; renaming the identifier is not offered, because it would silently detach everything
pointing at it.
Pick where the number comes from
The pickers only offer things this agent has actually recorded — the outcome kinds you have received, the metadata fields your traces carry, the labels your classifier has really emitted.
That is not a convenience. A measure pointing at an outcome kind nobody sends saves without
complaint and then renders a confident, permanent 0%. Neens refuses to let you create one.
Choose the grain
- A trace — one agent run.
- A case — one conversation, however many traces it took.
Business numbers almost always belong at the case grain. An agent that takes six turns to fail must not look six times worse than one that fails in a single turn.
(An outcome-sourced measure is case-grain automatically — an outcome is recorded about a case, so counting it per trace would count the same fact once per turn.)
Set the aggregation
rate is the common one for a KPI: “the fraction of measured cases where …”. Pick the comparison
and the threshold — >= 4 for CSAT 4 or better.
For a classifier source, a rate’s numerator is the labels you tick. Neens refuses to save a
classifier rate with no labels selected, because that rate is 0% forever — and a permanent zero
is a number, which renders, charts, and satisfies an alert threshold. An error is safer.
Say which way is good, and set a target
Higher is better / Lower is better drives the trend arrow and the default alert comparator.
The optional target renders as a goal line. For a rate, express the target as a fraction —
0.85, not 85.
Press Test it
This runs the real resolver over your real data for the last 7 days and shows you the number and its coverage before anything is saved. Nobody should have to publish a measure to a live exec dashboard to find out whether it returns anything.
Save
Worked example: deflection rate for a support org
Your helpdesk already pushes a containment outcome per ticket (see
Business outcomes). You want the number your VP of Support reports.
| Field | Value |
|---|---|
| Name | Deflection rate |
| Identifier | deflection-rate |
| Source | Business outcome → containment |
| One row is | A case |
| Aggregation | rate, counts when the value is = 1 |
| Which way is good | Higher is better |
| Target | 0.72 |
Badge: Measured. It appears in the gallery under Business as Deflection rate.
More examples
“What share of rated cases were happy ones.” Measured, because it comes from your CSAT feed.
{
"slug": "csat-good",
"label": "Happy customers",
"source": "outcome", "outcomeKind": "csat", "outcomeSource": "zendesk",
"grain": "case", "agg": "rate", "unit": "ratio",
"rateOp": ">=", "rateValue": 4,
"direction": "higher_is_better", "target": 0.8
}outcomeSource pins the signal to one system of record — useful when two systems disagree about
the same case. Leave it out to accept any source.
Coverage: the part everyone forgets
Every custom measure reports how many units it could actually read, alongside the number:
"coverage": { "rows": 412, "measured": 168, "rate": 0.41, "asOf": "2026-07-27T09:14:22Z" }That says: 412 cases fell in the window, 168 of them carried a readable signal, and the number above was computed over those 168.
A unit with no signal is excluded, never counted as a zero. This is the single most important rule in the feature, and it runs the other way from most reporting tools:
| Neens | The tempting alternative | |
|---|---|---|
| 412 cases, 168 with a CSAT score, 84 of them ≥ 4 | 50%, coverage 41% | 20% (84 ÷ 412) |
The second number is not more conservative — it is wrong, and it is wrong in a direction that changes decisions. Neens reports the honest rate and tells you the denominator it used.
Read the coverage before you act on the number. A 92% deflection rate over 8% of your cases is not a 92% deflection rate. The UI captions every partial number; the API returns the block above on every read.
When there is nothing to measure
A measure with no measured units resolves to null, and every surface renders —. It never
renders 0, because a zero looks like a real value: it charts, it trends, and it satisfies an
alert threshold.
Where your measure shows up
Once saved, a custom measure is a first-class catalogue measure:
- Measure gallery — under its category (default: Business), with its provenance badge.
- Dashboard widgets — pick it like any other measure. The widget tile shows your label, not the raw key.
- Alert rules — “page me when Deflection rate drops below 72%”. A
ratemeasure’s threshold is a fraction (0.72), and the rule builder pre-fills your declared direction and target. - Promoted to a KPI — when the measure stops
being something you watch and becomes something you committed to, promote it: an agent-scoped
KPI adds a target, an owner, a status and a review cadence on top of the measure, without
redefining or recomputing it. Your measure’s declared direction is what the KPI pre-fills, so you
are not asked the same question twice. Deleting a custom measure leaves any KPI promoted from it
reading
measure_removedrather than a wrong number — one more reason to check the blast radius first. Editing the measure’s rule is a smaller version of the same event: the KPI’s recorded history marks the edit as a series break and refuses to draw a trend across it, because the days either side are two different quantities.
Slicing
A measure can be broken down by the dimensions its shape supports — agent, time, project,
org, status, source, conversation, plus meta:<key> for a trace metadata field. An
outcome-sourced measure can also slice by queue and outcome_meta:<key>, because only it has
your outcome rows in scope.
Slicing by agent is the one worth calling out: a KPI you cannot break down per agent cannot tell you which agent moved it.
The daily digest
If you have the persona digest turned on, your measures ride along in it — up to four of them, after the four platform tiles, with your labels. A morning email is a glance rather than a report, so the cap is deliberate; the dashboard is where all of them live.
One surface is not automatic yet. Custom measures flow into the measure gallery, dashboard
widgets, alert rules and the persona digest. The SDK’s gate-as-code metric rule does not
pick them up: that rule gates a pre-prod run’s judge rollup and has no catalogue path at all
today, so wiring it is a separate change rather than a flag. The weekly narrative also still
reads a fixed measure list.
Scope: the definition is company-wide, what reads it is not
A custom measure has no agent scope. One definition belongs to the whole company, and every agent in it sees the same measure under the same key.
That is on purpose. A measure is your company’s shared vocabulary: “containment” has to mean the same thing in staging as it does in production, or the two numbers are not comparable. It is also what lets an Org- or Company-scoped dashboard chart one measure across several agents at once — impossible if each agent had its own private definition.
The things that point at a measure are the opposite — they are agent-scoped:
| Object | Scope | What it stores |
|---|---|---|
| The custom measure | The company | The definition (source, grain, aggregation, target) |
| An alert rule | One agent | The measure key (custom:deflection-rate) |
| A dashboard widget | One agent | The measure key |
So a change made from inside one agent lands everywhere:
- Deleting removes the definition for the entire company. Every alert rule that named it becomes a rule that can never fire again, and every widget that named it stops rendering a number — the tile shows an error where the chart used to be.
- Editing the definition redefines it for the entire company. A redefinition re-points every sibling agent’s alert rules and widgets at the new definition — the same rule, now watching a different number.
Nobody is told. An orphaned alert rule stays enabled and looks healthy: it simply resolves no value, so it never fires and never notifies you that it stopped. A widget at least shows its error, but only to whoever next opens that dashboard — which may be in an agent you never look at. That is why the confirmation tells you the whole blast radius before you commit — see Before you delete a shared measure.
Editing, and what stays fixed
Every row below applies company-wide, not just to the agent you are standing in.
| Change | Effect |
|---|---|
| Rename the label / description | Free. Cached values are not invalidated — the number did not change. |
| Change the target or direction | Free. Presentation only. |
| Change the source, kind, threshold, filters or reducer | The number changes, so every cached value is invalidated immediately — in every agent. |
| Change the identifier | Not offered. Alert rules and widgets store the key. |
That split is deliberate. Renaming a KPI should not blow every cached chart; redefining one must never keep serving yesterday’s answer.
Before you delete a shared measure
Deleting (or destructively redefining) a measure asks you to confirm, and the confirmation reports the full company-wide blast radius:
- everything in your current agent, by name — each alert rule, each dashboard widget;
- everything in other agents, as bare counts only — how many alert rules, how many widgets, across how many other agents.
Names, ids and dashboard titles from other agents are deliberately withheld. They belong to agents you may have no right to read, and “how much you are about to break” is answerable without disclosing what somebody else called it.
Neens warns; it does not refuse. After you confirm, the delete goes through even when the blast radius is non-zero. A mis-defined measure — one pointed at the wrong outcome kind, or one that has been rendering a wrong number for a month — has to remain removable, so the platform’s job here is to make sure nobody deletes it believing nothing else read it.
Worked example: checking first
The confirmation dialog runs the same pre-flight check you can run yourself, against
GET /custom-measures/references/{identifier}. Run it as an admin session, with the agent
header selecting the agent whose names you want spelled out:
curl https://your-neens-host/api/custom-measures/references/deflection-rate \
-H "Authorization: Bearer nk_sess_..." \
-H "X-Neens-Project-Id: proj_support_prod"{
"references": {
"alertRules": [
{ "id": "ar_1", "name": "Containment below 60%" }
],
"widgets": [
{ "id": "w_9", "title": null, "dashboardId": "dash_ops", "dashboardName": "Ops" }
],
"outOfScope": { "alertRules": 3, "widgets": 2, "projects": 2 }
}
}Read that as: one alert rule and one widget in this agent, named — plus three more
alert rules and two more widgets spread across two other agents in the company, counted.
Deleting deflection-rate breaks all seven of them.
The DELETE /custom-measures/{identifier} response carries the same references block, so a
script that deletes without checking first can still log exactly what it just broke.
How to read outOfScope
outOfScope has three states, and two of them look alike at a glance:
| What you get | What it means | What to do |
|---|---|---|
{"alertRules": 3, "widgets": 2, "projects": 2} | Other agents do depend on this measure | Coordinate before deleting (below) |
{"alertRules": 0, "widgets": 0, "projects": 0} | Nothing outside your agent references it | Safe to delete once the named local references are handled |
| (the key is absent) | Not established — either not disclosed to your credential, or the count could not be completed. Says nothing about whether references exist | Re-check with a credential that can perform the delete; if it stays absent, treat the impact as unknown |
outOfScope is only disclosed to a caller who could actually perform the delete or the edit — an
admin (MANAGE_MEASURES). An agent-scoped nk_live_ API key can read the route, so it still
sees the named references in its own agent, but it never receives the outOfScope key at all:
curl https://your-neens-host/api/custom-measures/references/deflection-rate \
-H "Authorization: Bearer nk_live_9f2c1a7b4e05d8..."{
"references": {
"alertRules": [{ "id": "ar_1", "name": "Containment below 60%" }],
"widgets": [{ "id": "w_9", "title": null, "dashboardId": "dash_ops", "dashboardName": "Ops" }]
}
}That response is not evidence that nothing else references the measure. It is the same response you would get if forty widgets in six other agents depended on it.
Absent is not zero. If you are scripting a cleanup, test for the key’s presence before you
trust its contents — if "outOfScope" not in refs: abort(). Treating a missing outOfScope as
“nothing else uses it” is exactly how an automated tidy-up deletes the measure another team’s
paging rule was built on, and nothing anywhere raises an error.
When the blast radius is non-zero
You have two honest options, and neither is “delete it and see who complains”:
- Coordinate first. The counts tell you how many owners to talk to (
projects) and how much they lose (alertRules,widgets). Ask each agent’s admins to repoint their alert rules and widgets at a replacement measure, or to remove them — then re-run the check and delete onceoutOfScopereads all zeros. - Keep the key, change nothing structural. If the problem is the name, rename the label: that is free, company-wide, and breaks nothing. If the problem is the definition, remember that a redefinition re-points every one of those rules and widgets at the new number rather than blanking them — often a better outcome than deleting, but only if the other agents are expecting it.
In your own agent, handle the named references directly: edit each alert rule to watch a different measure (or delete it), and repoint or remove each widget on its dashboard.
Platform measures stay ours
The measures Neens ships (error_rate, spend_usd, containment_rate, …) cannot be edited or
overwritten by a tenant. Attempting it returns a 409.
This is not gatekeeping — it is a correctness boundary. spend_usd is a token roll-up through a
dated price table; error_rate is a predicate over a trace’s status column. None of that is
expressible in the source grammar above, so an “editable copy” would compute something different
under a familiar name. If you want a differently-defined version, define your own measure against
your own source and give it whatever label you like — including the same one.
Your measures are namespaced custom:<identifier>, so a collision with a platform key is
impossible by construction.
Validation: why a save can fail
Neens validates a definition against the data your agent actually has, at save time.
| Result | When | What happens |
|---|---|---|
| Error | You reference an outcome kind / metric / label that does not exist, and this agent has others | Save is refused, 422, the offending field is named and the real options are listed |
| Warning | This agent has recorded nothing of that type yet | Save succeeds, with a note that it will render blank until data arrives |
| Skipped | We could not enumerate that vocabulary at all | No finding — a lookup that could not run must never masquerade as a definition that is wrong |
The middle row matters: on a fresh agent, “wrong” and “not yet” are indistinguishable, and refusing would make it impossible to define a KPI the day before a feed goes live.
Other refusals, each because the alternative is a plausible-looking wrong number:
- A classifier rate with no labels — it would be
0%forever. - A
ratiounit on a non-rate aggregate — a percent sign over a number that is not a fraction. - An outcome measure at trace grain — it would count one case’s outcome once per turn.
- Labels on a numeric aggregate — they would be silently ignored, leaving you believing you had filtered.
API reference
All routes are admin-gated for writes (MANAGE_MEASURES) and READ for reads.
| Method | Path | Purpose |
|---|---|---|
GET | /custom-measures | Your measures + the platform catalogue + your remaining quota |
GET | /custom-measures/options | The grammar and what this agent has actually recorded |
POST | /custom-measures | Define one |
PATCH | /custom-measures/{identifier} | Edit one — company-wide |
DELETE | /custom-measures/{identifier} | Delete one — company-wide; returns what referenced it, including outOfScope |
GET | /custom-measures/references/{identifier} | What reads it, before you change anything: local references by name, other agents as counts |
POST | /custom-measures/preview | Resolve an unsaved definition over a bounded window |
Preview
curl -X POST https://your-neens-host/api/custom-measures/preview \
-H "Authorization: Bearer $NEENS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"definition": { "slug": "draft", "label": "Draft", "source": "outcome",
"outcomeKind": "csat", "grain": "case", "agg": "rate",
"unit": "ratio", "rateOp": ">=", "rateValue": 4 },
"days": 7,
"dimensions": ["agent"]
}'{
"rows": [
{ "agent": "billing-bot", "custom:draft": 0.83 },
{ "agent": "shipping-bot", "custom:draft": 0.51 }
],
"meta": {
"custom": {
"provenance": "measured",
"coverage": { "rows": 412, "measured": 168, "rate": 0.41, "asOf": "2026-07-27T09:14:22Z" }
}
},
"windowDays": 7,
"windowClamped": false,
"problems": []
}Preview windows are capped at 30 days because an unsaved definition has no fingerprint to cache against, so every call is a real query. A saved measure has no such cap — it rides the normal read cache like everything else.
Limits and plan
Each company can define up to 50 custom measures; creating one past that limit returns a 409 naming the cap (delete one before adding another). The Test it preview window is capped at 30 days — a saved measure has no such cap, since it rides the normal read cache.
Authoring requires the custom_measures feature (Silver and above). Reading and charting an
existing custom measure is available on every plan — a downgrade never blanks a dashboard you
already built, it only stops you adding more.
See also
- Business KPIs — the three named KPIs and the definitions behind them
- Promote a measure to a KPI — turn this measure into a commitment with a target, an owner and a review cadence
- KPI history and trends — what a promoted measure’s daily record looks like, and what editing this measure does to its trend
- Business outcomes — getting the
measureddata in - Metrics catalogue — the platform measures and dimensions
- Alert rules — paging on a measure crossing a threshold
- Dashboards — putting a measure on a board