GuidesCustom measures

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 teamWhat “handled it” actually means
SupportResolved without a human ever touching it
SalesQualified without an SDR call
Internal ITThe ticket was never created in the first place
CollectionsPayment 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

WhereThe Custom measures tab of the Business KPIs page
WhoAdmins (MANAGE_MEASURES); Silver plan and above
ScopeCompany-wide — one definition, shared by every agent. See Scope
ReadsAnyone with READ — a viewer can see what a number means
SourcesA business outcome, a trace metadata field, or a classifier/judge metric
GrainsOne trace, or one case (a conversation — many traces)
Aggregationscount, sum, avg, p50, p90, p99, rate
Appears inMeasure 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.

SourceReadsBadgeWhat it means
Business outcomeA business outcome kindMeasuredYour system of record recorded this. The strongest claim available.
Trace metadataA field in the trace’s metadataEmittedYour agent asserted this about itself. Useful, but nothing confirmed it.
Classifier / judgeA score metric + its labelsInferredA 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.

FieldValue
NameDeflection rate
Identifierdeflection-rate
SourceBusiness outcome → containment
One row isA case
Aggregationrate, counts when the value is = 1
Which way is goodHigher is better
Target0.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:

NeensThe tempting alternative
412 cases, 168 with a CSAT score, 84 of them ≥ 450%, 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 rate measure’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_removed rather 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:

ObjectScopeWhat it stores
The custom measureThe companyThe definition (source, grain, aggregation, target)
An alert ruleOne agentThe measure key (custom:deflection-rate)
A dashboard widgetOne agentThe 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.

ChangeEffect
Rename the label / descriptionFree. Cached values are not invalidated — the number did not change.
Change the target or directionFree. Presentation only.
Change the source, kind, threshold, filters or reducerThe number changes, so every cached value is invalidated immediately — in every agent.
Change the identifierNot 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 getWhat it meansWhat to do
{"alertRules": 3, "widgets": 2, "projects": 2}Other agents do depend on this measureCoordinate before deleting (below)
{"alertRules": 0, "widgets": 0, "projects": 0}Nothing outside your agent references itSafe 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 existRe-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”:

  1. 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 once outOfScope reads all zeros.
  2. 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.

ResultWhenWhat happens
ErrorYou reference an outcome kind / metric / label that does not exist, and this agent has othersSave is refused, 422, the offending field is named and the real options are listed
WarningThis agent has recorded nothing of that type yetSave succeeds, with a note that it will render blank until data arrives
SkippedWe could not enumerate that vocabulary at allNo 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 ratio unit 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.

MethodPathPurpose
GET/custom-measuresYour measures + the platform catalogue + your remaining quota
GET/custom-measures/optionsThe grammar and what this agent has actually recorded
POST/custom-measuresDefine 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/previewResolve 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