Business outcomes
A business outcome is your own fact about a case: this case was resolved without an escalation, CSAT was 4, handle time was 6m12s, the refund was reversed. It lives in your helpdesk, your CRM, or your warehouse — not in the trace — and it almost always arrives after the agent has finished running.
Neens accepts that fact, keys it to the case the agent actually handled, and keeps it as evidence alongside the trace. Nothing here is inferred: a judge’s opinion that an answer looked helpful is a score; the customer clicking 4 stars is an outcome. Only one of those is the business result, and this page is about the second one.
No LLM is involved anywhere in this path. Your outcome is carried faithfully — never summarized, never guessed. A key that matches two cases stays ambiguous instead of picking one, and a missing value is rejected instead of stored as a zero.
At a glance
| Where | The Data sources tab of the Business KPIs page (connectors, correlation settings, and the coverage panel; connecting a feed is admin-only). Individual outcomes also appear on the trace/conversation detail page. |
| Two ways in | Push — POST /outcomes from your ETL or app backend. Pull — a scheduled helpdesk connector. |
| Key API | POST /outcomes · GET /outcomes · GET /outcomes/coverage · GET /outcomes/kinds · GET/PUT /outcomes/settings · POST /outcomes/reconcile · GET /outcomes/{id}/revisions |
| Auth to push | The same nk_live_ agent API key you already use to send traces — no separate credential for your ETL job. |
| Auth to configure | Admin (reading outcomes only needs read access; changing correlation settings or running a reconcile is admin-only). |
| Needs an LLM connection? | No. This path makes no model calls at all. |
The case grain
Neens keys every outcome to a case, which is conversation_id when the trace has one and the
trace’s own id when it doesn’t — exactly the rollup you already see on the Sessions page (see
Traces & sessions). A multi-turn conversation is one case, not
one case per turn, so “handle time was 6m12s” attaches to the whole exchange rather than to a single
model call.
When an outcome matches, Neens records both the case key (conversationId) and the case’s first
trace (sessionId), so you can jump from the outcome straight into the conversation that produced
it.
Choose a correlation type
correlationType tells Neens how to find the case your correlationKey refers to. There are
three, and picking the right one is the single biggest lever on your match rate.
correlationType | correlationKey is… | Use it when |
|---|---|---|
conversation (default) | The case key — your agent’s conversation_id, or a single trace’s id when there is no conversation | Your agent already uses the ticket/chat id from your system of record as its conversation id. This is the cleanest option — prefer it. |
session | One specific trace id | You want the fact attached to a single agent run rather than the whole conversation. |
external | An id your agent wrote into the trace’s metadata — a ticket id, a case id, a CRM id | Your agent’s conversation id and your helpdesk’s ticket id are different values, but the agent emits the ticket id as trace metadata. |
For external, Neens walks a list of dotted paths into each session’s metadata JSON and takes the
first non-empty scalar it finds. Out of the box those paths are:
ticket_id · case_id · external_id · crm_id · attributes.ticket_id · tags.ticket_idChange them per agent under the Data sources tab of Business KPIs, or with
PUT /outcomes/settings (see Settings).
external matching is a scan, not an index lookup: Neens walks the agent’s newest sessions
that carry metadata, up to a fixed ceiling (20,000 by default). If the scan hits
that ceiling without finding the key, the outcome says so in its match detail — so you can tell
“no such key” from “we didn’t look far enough”. conversation and session are indexed lookups and
have no such bound.
Push an outcome
Get an agent API key
Use the same nk_live_… key you send traces with — posting an outcome needs write access to the
agent, which an agent key already has. See API keys.
export NEENS_BASE_URL="https://neens.example.com" # your Neens origin
export NEENS_API_KEY="nk_live_..." # your agent API keyPOST the fact
Send one outcome, or a batch of up to 500 in outcomes[] — a larger batch is rejected with a
422. Field names are camelCase.
curl -sf -X POST "$NEENS_BASE_URL/api/outcomes" \
-H "Authorization: Bearer $NEENS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source": "zendesk_etl",
"outcomes": [
{
"correlationKey": "ZD-10231",
"correlationType": "external",
"kind": "resolution",
"value": true,
"occurredAt": "2026-07-26T10:04:00Z",
"metadata": {"queue": "billing"},
"idempotencyKey": "zd-10231-resolution-v1",
"externalId": "10231"
},
{
"correlationKey": "ZD-10231",
"correlationType": "external",
"kind": "csat",
"value": 4,
"occurredAt": "2026-07-26T11:20:00Z",
"idempotencyKey": "zd-10231-csat-v1"
},
{
"correlationKey": "ZD-10231",
"correlationType": "external",
"kind": "resolution_time",
"value": 372,
"unit": "s",
"occurredAt": "2026-07-26T10:04:00Z",
"idempotencyKey": "zd-10231-rt-v1"
}
]
}'Read the response
The response is a per-batch report. 201 when at least one record was written, 200 when
everything deduplicated.
{
"accepted": 3,
"created": 3,
"revised": 0,
"deduplicated": 0,
"rejected": [],
"matched": 3,
"unmatched": 0,
"ambiguous": 0,
"outcomes": [
{
"id": "bo_ab12c3d45e6f7890",
"seriesKey": "9f3c…",
"projectId": "proj_x",
"correlationType": "external",
"correlationKey": "ZD-10231",
"kind": "resolution",
"kindLabel": "Resolved",
"source": "zendesk_etl",
"valueType": "boolean",
"value": true,
"unit": null,
"occurredAt": "2026-07-26T10:04:00+00:00",
"receivedAt": "2026-07-26T10:07:41.118932+00:00",
"metadata": {"queue": "billing"},
"revision": 1,
"isCurrent": true,
"supersededAt": null,
"supersededBy": null,
"matchState": "matched",
"sessionId": "ses-1a2b3c",
"conversationId": "conv-77f0",
"matchedAt": "2026-07-26T10:07:41.118932+00:00",
"matchAttempts": 1,
"matchDetail": null,
"matchDeadline": "2026-07-29T10:04:00+00:00",
"connectionId": null,
"externalId": "10231",
"createdBy": "[email protected]",
"createdAt": "2026-07-26T10:07:41.118932+00:00"
}
]
}Partial success is normal and correct. One bad row never bounces the batch: it lands in
rejected with an honest reason ("value is required (a null value is a rejection, not a zero).")
and its siblings are still written. A nightly export shouldn’t fail because one ticket had a
malformed CSAT.
Request fields
| Field | Required | Notes |
|---|---|---|
correlationKey | yes | Max 512 characters. |
correlationType | no | conversation (default) · session · external. |
kind | yes | A slug from the catalogue — or your own (see below). |
value | yes | null is a rejection, not a zero. |
unit | no | Defaults per kind. Durations accept ms, s/seconds, m/minutes, h/hours. |
valueType | no | numeric · boolean · duration · currency · categorical. Required only for a kind outside the catalogue (otherwise inferred from the value’s type). |
occurredAt | no | When it happened upstream. Defaults to now when omitted. ISO-8601, or epoch seconds/milliseconds. A value Neens cannot parse is a rejection, not a silent fallback to now — otherwise a mis-formatted date would misfile the fact into the wrong reporting window. More than 24h in the future is also rejected (an outcome records something that already happened, and a future stamp would never expire and never be purged). |
metadata | no | A JSON object, capped at 16384 bytes (16 KiB) per record; over the cap rejects that record, not the batch. |
idempotencyKey | no | Max 256 characters. See Idempotency. |
externalId | no | The record id in your system (the Zendesk ticket id, the Salesforce case id). Stored for traceability; it is not used for matching. |
source | no | A slug identifying who asserted this — push by default. Set it once at the top level for the whole batch, or per record. |
A top-level source applies to every record in the batch; a per-record source overrides it.
The outcome catalogue
GET /outcomes/kinds returns the platform catalogue plus the valid correlation types, match states
and value types. These are the curated kinds the UI offers:
kind | Label | Value type | Default unit | Direction |
|---|---|---|---|---|
resolution | Resolved | boolean | — | higher is better |
containment | Contained | boolean | — | higher is better |
escalation | Escalated | boolean | — | lower is better |
reopened | Reopened | boolean | — | lower is better |
resolution_time | Resolution time | duration | ms | lower is better |
first_response_time | First response time | duration | ms | lower is better |
handle_time | Handle time | duration | ms | lower is better |
csat | CSAT | numeric | score | higher is better |
nps | NPS | numeric | score | higher is better |
refund_amount | Refund amount | currency | usd | lower is better |
case_cost | Case cost | currency | usd | lower is better |
transfer_count | Transfers | numeric | count | lower is better |
outcome_label | Outcome label | categorical | — | not directional |
How each value type is stored
- boolean — accepts
true/false,1/0, and the stringstrue/t/yes/y/1andfalse/f/no/n/0. Anything else is rejected; a boolean outcome is never a count. - duration — normalized to milliseconds on the way in, whatever unit you send, so a connector reporting seconds and an ETL reporting minutes stay comparable.
- currency — the unit must be a three-letter code (
usd,eur); it defaults tousd. - numeric — stored as a float, with your unit kept as-is (
score,count, …). - categorical — the string itself, up to 512 characters. Not a number, so no arithmetic.
A null value is always a rejection. Storing 0 for “we don’t know” is how a containment metric
quietly becomes a lie.
A kind outside the catalogue is accepted. Send "kind": "renewal_saved" with an explicit
valueType (or a value whose Python/JSON type makes it obvious) and Neens stores it, catalogues it
under a humanized label, and counts it in coverage. The curated list is what the UI suggests, not a
whitelist.
Idempotency and restatement
These are two different things, and the distinction is what keeps your history truthful.
Idempotency is “you already told me this”. Restatement is “you’ve changed your mind”.
A series is the identity of one fact about one case:
(project, correlationType, correlationKey, kind, source). A series has one or more revisions,
and exactly one of them is current. Nothing is ever overwritten and nothing is deleted — the
superseded revision stays as evidence.
Neens decides which of the two happened in this order:
Same idempotencyKey?
If a record with that key already exists in the agent for the same series, the new one is deduplicated — the existing outcome is returned unchanged. This is the strongest guarantee, and the one your ETL should reach for: retrying a whole nightly batch costs nothing.
A key is scoped to the agent, so it must be unique per (correlationKey, kind, source). Reusing
one key for several different facts about the same case is rejected, not silently dropped:
// REJECTED — one key, three different kinds. Append the kind instead ("zd-10231-csat").
[{"correlationKey": "ZD-10231", "kind": "resolution", "value": true, "idempotencyKey": "zd-10231"},
{"correlationKey": "ZD-10231", "kind": "csat", "value": 4, "idempotencyKey": "zd-10231"}]Same content?
Otherwise Neens content-hashes the value, unit, occurrence time and metadata. If that matches the series’ current revision, it’s deduplicated too — a byte-identical re-poll must not manufacture a change that never happened.
Otherwise, a new revision
The current revision is superseded and revision N+1 becomes current. The response counts it as
revised (or created, if the series is new).
So a reopened ticket looks like this — same key, same kind, same source, a different idempotency key:
curl -sf -X POST "$NEENS_BASE_URL/api/outcomes" \
-H "Authorization: Bearer $NEENS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source": "zendesk_etl",
"correlationKey": "ZD-10231",
"correlationType": "external",
"kind": "resolution",
"value": false,
"occurredAt": "2026-07-27T09:15:00Z",
"metadata": {"reopenReason": "customer replied: charge still on the statement"},
"idempotencyKey": "zd-10231-resolution-v2"
}'In the UI, the Business outcomes section on the trace’s detail page marks that outcome Revised (rev 2) and expands its Revision history to show the value it used to hold. Over the API the whole series is readable:
curl -sf "$NEENS_BASE_URL/api/outcomes/bo_ab12c3d45e6f7890/revisions" \
-H "Authorization: Bearer $NEENS_API_KEY"{
"seriesKey": "9f3c…",
"total": 2,
"revisions": [
{"revision": 1, "value": true, "isCurrent": false, "supersededAt": "2026-07-27T09:16:02+00:00", "supersededBy": "bo_7f9b46b1aebf4013", "…": "…"},
{"revision": 2, "value": false, "isCurrent": true, "supersededAt": null, "supersededBy": null, "…": "…"}
]
}source is part of the series identity, on purpose. Zendesk saying “resolved” and a warehouse
ETL saying “resolved” are two independent assertions about the same case, and collapsing them would
let one silently overwrite the other. If you want one to restate the other, send both under the same
source.
Late arrival: matched, parked, expired
An outcome routinely lands before its trace does — a helpdesk fires the moment a ticket closes, while the agent’s spans are still in flight. Neens never drops those.
Every record is correlated at write time, so a same-day outcome is matched immediately and never waits for a sweep. Whatever doesn’t match is parked, not discarded:
matchState | Meaning | What happens next |
|---|---|---|
matched | Resolved to exactly one case. | Done. |
unmatched | No case for that key — yet. | Retried by the reconcile sweep until the match deadline. |
ambiguous | The key resolved to more than one case. Neens never guesses. | Retried, and stays ambiguous until the keys stop colliding. |
expired | Still unresolved past the match deadline. | Retried no more. Never deleted — it still counts in coverage. |
The match deadline is occurredAt + the match window (72 hours by default), which you
can override per agent under the Data sources tab of Business KPIs. A background sweep periodically
re-attempts the oldest parked rows first and stamps every attempt — so a permanently unmatchable row
shows as “tried 40 times” rather than looking like a fresh arrival.
To run it now for the current agent, click Reconcile now on the Match coverage panel, or call the API (admin only):
curl -sf -X POST "$NEENS_BASE_URL/api/outcomes/reconcile" \
-H "Authorization: Bearer $SESSION_TOKEN"{"examined": 12, "matched": 4, "ambiguous": 1, "expired": 2, "stillUnmatched": 5}Read the coverage panel
GET /outcomes/coverage is the surface this whole feature exists for. A containment rate computed
off a 60%-matched set of outcomes is confidently wrong, and nothing else in the product would tell
you.
curl -sf "$NEENS_BASE_URL/api/outcomes/coverage?since=2026-07-01" \
-H "Authorization: Bearer $NEENS_API_KEY"{
"summary": {
"total": 96, "matched": 86, "unmatched": 7, "ambiguous": 1, "expired": 2,
"matchRate": 0.8958, "unresolvedRate": 0.1042
},
"byKind": [
{"kind": "resolution", "total": 28, "matched": 18, "unmatched": 7, "ambiguous": 1, "expired": 2, "matchRate": 0.6429, "unresolvedRate": 0.3571}
],
"bySource": [
{"source": "zendesk_etl", "total": 78, "matched": 68, "…": "…"}
],
"byCorrelationType": [
{"correlationType": "conversation", "total": 66, "matched": 66, "matchRate": 1.0, "…": "…"}
],
"recentUnmatched": [
{"id": "bo_…", "kind": "resolution", "correlationKey": "ZD-77104", "correlationType": "external",
"occurredAt": "2026-07-26T…", "matchAttempts": 3,
"matchDetail": "external key 'ZD-77104' not found in any session's metadata"}
],
"oldestUnmatchedAt": "2026-07-20T08:12:00+00:00"
}Read it in this order:
summary.matchRate— the headline. Every downstream number is only as trustworthy as this.byCorrelationType— usually where the problem is. Aconversationrow at1.0next to anexternalrow at0.55tells you the correlation type, not the data, is wrong.byKind/bySource— narrows it to one exporter or one fact.recentUnmatched— up to 20 parked outcomes with the actual reason each one failed. This is the fastest path to a fix. The sample is redacted, so a ticket subject or a requester email inmetadatanever leaks into a shared screenshot.
matchRate is null, not 1.0, when there are no outcomes yet. Reporting “100% matched” for
an empty denominator is exactly the failure this surface exists to prevent, so the API returns
null and the panel says no outcomes yet.
Filter coverage with since, until, kind and source to check one exporter in isolation.
Why your match rate is not 100%
A brownfield deployment typically sits somewhere in the 85–95% range, and that’s fine — as long as
you know why. Every parked outcome carries a matchDetail explaining itself; here is what each
state means and what actually fixes it.
| Symptom | What it means | What to change |
|---|---|---|
unmatched — “no case with conversation key ‘X‘“ | You sent a conversation key that isn’t any case’s conversation_id (nor any trace id). | Check what your agent actually sets as conversation_id. If your helpdesk ticket id and the agent’s conversation id are different values, you want correlationType: "external", not conversation. |
unmatched — “external key ‘X’ not found in any session’s metadata” | The agent never wrote that id into the trace, or wrote it under a path Neens isn’t looking at. | Instrument the agent to emit the id (ticket_id is the first default path), or add the path you do emit under the Data sources tab of Business KPIs → external key paths. |
unmatched — “external key ‘X’ not found within the newest N sessions” | The scan ceiling was hit before the key was found — the case may simply be older than the window Neens scanned. | Narrow the export so it only sends recent cases. Do not read this as “no such key” — Neens is explicitly telling you it stopped early. |
unmatched on brand-new outcomes only, clearing later | Normal late arrival: the outcome beat the trace. | Nothing. The sweep picks it up automatically. |
unmatched and it never clears | Your system of record exports cases the agent never handled — phone and email tickets, for instance. | Filter them out of the export (e.g. only tickets whose channel is the bot), or accept the gap knowingly: coverage now shows it rather than hiding it. |
ambiguous — “external key ‘X’ matches N cases” | One id appears in the metadata of more than one case. Usually the agent reuses a ticket id across conversations, or a generic key like crm_id matches a customer rather than a case. | Use a key that is unique per case, or narrow the external key paths so a broader field (crm_id) can’t win. Neens will not pick one for you. |
expired | Still unresolved past occurredAt + the match window. Retrying stopped. | If the traces genuinely arrive late, raise the match window per agent under the Data sources tab of Business KPIs. Otherwise treat it as a real gap — it’s a case whose trace never came. |
| Coverage says no outcomes yet | Nothing has been recorded for this filter window. | Widen since/until, or check that your ETL is actually posting (GET /outcomes?limit=1). |
Fixed the cause? Parked rows are re-attempted automatically, or immediately with
POST /outcomes/reconcile. Rows already marked expired are not retried — re-post them if they
matter.
Agent settings
Both settings live on the Data sources tab of Business KPIs, under Correlation settings,
as External key paths (one per line) and Match window (hours) — leave either blank to inherit the platform
default. GET /outcomes/settings shows the effective configuration and the defaults it falls back
to; PUT /outcomes/settings changes it (admin only).
curl -sf -X PUT "$NEENS_BASE_URL/api/outcomes/settings" \
-H "Authorization: Bearer $SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{"externalKeyPaths": ["ticket_id", "attributes.zendesk_ticket"], "matchWindowHours": 168}'| Setting | Meaning |
|---|---|
externalKeyPaths | Dotted paths walked into the trace’s metadata for an external match, in order; the first non-empty scalar wins. Empty ⇒ the platform defaults. |
matchWindowHours | How long a parked outcome stays retryable. null ⇒ the platform default (72). Accepted range 0–8760. |
Browse recorded outcomes
curl -sf -G "$NEENS_BASE_URL/api/outcomes" \
-H "Authorization: Bearer $NEENS_API_KEY" \
--data-urlencode "kind=csat" \
--data-urlencode "matchState=matched" \
--data-urlencode "limit=50"| Query parameter | Notes |
|---|---|
kind, source, matchState, correlationKey | Exact match. |
conversationId, sessionId | The resolved case / trace — use these to list a single conversation’s outcomes. |
since, until | Bounds on occurredAt. |
includeSuperseded | false by default — only current revisions. Set true to see the full history inline. |
limit, offset | limit defaults to 50, max 500. |
GET /outcomes/{id} returns one outcome; GET /outcomes/{id}/revisions returns its whole series
oldest → newest.
What’s next
- Connect your helpdesk — pull outcomes from Zendesk, Intercom, Salesforce or Jira Service Management on a schedule instead of pushing them.
- Business KPIs — turn these outcomes into containment rate, resolution
time and cost per case, measured per case and badged with their provenance and coverage. Once a
number is one you report on, promote it to a
KPI: a target, a direction, an owner and a
review cadence on top of the same measure. Because outcomes arrive late, every KPI value carries
its coverage and an
asOf— and a window with no decided cases readsunknown, nevermissed. Late arrivals are also why a KPI’s recorded daily history re-checks its most recent days: an outcome that lands on Thursday corrects the figure already recorded for Monday. - Traces & sessions — the case grain outcomes are keyed to.
- Fix outcomes — the other outcome surface: whether a merged fix actually reduced a failure in production.