Use cases / Customer analytics

Reasons for customer churn

Who is about to cancel, and why.

Unit
One customer, last 7 days of tickets
Ask
Cancel intent, concern, impact. Then: why.
Get
38 at-risk accounts, each with a reason and a receipt

Pylon · Attio · 2 reducers · 5 questions · updated Sep 21, 2026

The flow

0 tickets source snapshot v12
0 Group by customer group no model call
0 Customer signals reducer requests, 3 questions each
0 Keep high-risk filter p(cancel) ≥ 0.65
0 Attach history join notes from Attio
0 Why might they leave? reducer requests, 2 questions each
0 Outputs output rows, each with a receipt

Fewer rows at every deterministic step. Deeper questions at every reducer. 412 requests, 1,236 judgments. Asked one at a time: 2,472 requests.

Follow one record

What comes out

Customer risk reviewrun 7f3a2c19, 38 rows, sorted by p(cancel)
Customerp(cancel)ConcernImpactLikely reasonExplicit
Northstar1.00reliabilityCritical work is blockedRepeated outagesyes 0.97
Atlas0.91supportCritical work is blockedUnresolved escalationyes 0.93
Halyard Freight0.78pricingWork is delayedCostyes 0.88
Corvid Systems0.72reliabilityWork is delayedRepeated outagesno 0.41
Larkspur Health0.66missing featureWork is delayedCompetitoryes 0.79

Synthetic rows in the app's Outputs layout. Northstar's first-stage numbers are the ones the app recorded on the example data; the rest are illustrative. Export is CSV or JSON with the trace IDs attached.

Details, if you want them

whyWhy this shape

Why this shape

  • Unit: the customer, not the ticket. Two angry tickets are one relationship.
  • One request, three questions. 1,284 tickets, 412 requests. One question at a time: 1,236.
  • Filter before you read more. History is read for 38 customers, not 412.
  • Distributions, not labels. Close calls look like close calls.
  • The receipt. Why is Northstar on the list? Two clicks, not a re-run.

When the questions change

  1. Edit a question. A new version is saved.
  2. Old runs keep the old version.
  3. Replay to apply the new wording to old data. New run, parent kept.
questionsThe 5 questions, verbatim
  • Customer signals cancelbinary, noul

    Does the customer in `tickets` express an intention or a threat to cancel or not renew?

  • Customer signals concerncategorical, choice

    What is the primary concern raised in `tickets`?

    reliabilitypricingsupportmissing featureother
  • Customer signals impactordinal, score

    How severe is the impact on the customer’s work described in `tickets`?

    Minor inconvenienceWork is delayedCritical work is blocked
  • Why might they leave? reasoncategorical, choice

    Considering `tickets` and `history`, what is the most likely reason this customer would leave?

    repeated outagesunresolved escalationcostcompetitorother
  • Why might they leave? explicitbinary, noul

    Is that reason stated explicitly in the customer’s own words in `tickets` or `history`, rather than inferred?

buildHow each stage is built, as JSON
1 group Group by customer
A deterministic operation. Tickets are grouped on customer_id; each group is one unit for the next reducer. Choosing the unit is the design decision here: per ticket would score messages, per customer scores the relationship. Grouping keeps the members, so the trace can show every ticket behind a score.

stage definition

{
  "id": "customers",
  "input": "source:tickets",
  "key": [
    "customer_id"
  ],
  "kind": "group",
  "name": "Group by customer"
}
2 reducer Customer signals
One request per customer. All three questions travel together because each can be answered from the same state, and the answers do not leak into each other. The state is the group’s tickets; the model sees nothing else. The executor freezes the model profile (endpoint, wire model, limits) into the run.

stage definition

{
  "id": "signals",
  "input": "customers",
  "kind": "evaluate",
  "model": "jev",
  "name": "Customer signals",
  "questions": {
    "cancel": {
      "instructions": "Does the customer in `tickets` express an intention or a threat to cancel or not renew?",
      "type": "noul"
    },
    "concern": {
      "criteria": {
        "missing_feature": "A capability the product lacks",
        "other": "Another concern",
        "pricing": "Cost, price or unexpected charges",
        "reliability": "Outages, broken features or failures",
        "support": "Slow or unhelpful support"
      },
      "instructions": "What is the primary concern raised in `tickets`?",
      "type": "choice"
    },
    "impact": {
      "criteria": [
        "Minor inconvenience",
        "Work is delayed",
        "Critical work is blocked"
      ],
      "instructions": "How severe is the impact on the customer's work described in `tickets`?",
      "type": "score"
    }
  },
  "state_path": []
}
3 filter Keep high-risk customers
A comparison on a stored field. The threshold is a policy you own, written down where anyone can read it. The filter emits a matched collection and an excluded collection; the excluded customers keep their probabilities and appear on the trace map, dashed, in every later column.

stage definition

{
  "comparison": "gte",
  "field": [
    "answers",
    "cancel",
    "probability"
  ],
  "id": "at_risk",
  "input": "signals",
  "kind": "filter",
  "name": "Keep high-risk customers",
  "value": 0.65
}
4 join Attach customer history
A join on the customer key against the Attio snapshot. Only the 38 kept customers get history attached, so the expensive context is read only where it will be used. Missing history is an explicit null, not a silent drop.

stage definition

{
  "id": "context",
  "input": "at_risk",
  "kind": "join",
  "left_key": [
    "input",
    "key"
  ],
  "name": "Attach customer history",
  "right": "source:history",
  "right_key": [
    "customer_id"
  ]
}
5 reducer Why might they leave?
The second reducer exists for one reason: its questions need data the first request did not have. The state now carries the first stage’s answers, the tickets and the joined notes. Two questions, one request per kept customer. The explicit question separates what the customer said from what the model inferred.

stage definition

{
  "id": "reasons",
  "input": "context",
  "kind": "evaluate",
  "model": "jev",
  "name": "Why might they leave?",
  "questions": {
    "explicit": {
      "instructions": "Is that reason stated explicitly in the customer's own words in `tickets` or `history`, rather than inferred?",
      "type": "noul"
    },
    "reason": {
      "criteria": {
        "competitor": "Evaluating or moving to an alternative",
        "cost": "Price or unexpected charges",
        "other": "Another reason",
        "repeated_outages": "Repeated product failures or outages",
        "unresolved_escalation": "An escalation that was not resolved"
      },
      "instructions": "Considering `tickets` and `history`, what is the most likely reason this customer would leave?",
      "type": "choice"
    }
  },
  "state_path": []
}
6 definition The whole workflow, as saved

Saving publishes an immutable version. A run freezes this definition, the snapshot IDs and each reducer’s model profile, so the trace can always show exactly what ran. Edits create a new version; old runs keep theirs.

jevreduce.local.v1

{
  "name": "Reasons for customer churn",
  "schema_version": "jevreduce.local.v1",
  "sources": {
    "history": "snap_attio_notes_v3",
    "tickets": "snap_pylon_tickets_v12"
  },
  "stages": [
    {
      "id": "customers",
      "input": "source:tickets",
      "key": [
        "customer_id"
      ],
      "kind": "group",
      "name": "Group by customer"
    },
    {
      "id": "signals",
      "input": "customers",
      "kind": "evaluate",
      "model": "jev",
      "name": "Customer signals",
      "questions": {
        "cancel": {
          "instructions": "Does the customer in `tickets` express an intention or a threat to cancel or not renew?",
          "type": "noul"
        },
        "concern": {
          "criteria": {
            "missing_feature": "A capability the product lacks",
            "other": "Another concern",
            "pricing": "Cost, price or unexpected charges",
            "reliability": "Outages, broken features or failures",
            "support": "Slow or unhelpful support"
          },
          "instructions": "What is the primary concern raised in `tickets`?",
          "type": "choice"
        },
        "impact": {
          "criteria": [
            "Minor inconvenience",
            "Work is delayed",
            "Critical work is blocked"
          ],
          "instructions": "How severe is the impact on the customer's work described in `tickets`?",
          "type": "score"
        }
      },
      "state_path": []
    },
    {
      "comparison": "gte",
      "field": [
        "answers",
        "cancel",
        "probability"
      ],
      "id": "at_risk",
      "input": "signals",
      "kind": "filter",
      "name": "Keep high-risk customers",
      "value": 0.65
    },
    {
      "id": "context",
      "input": "at_risk",
      "kind": "join",
      "left_key": [
        "input",
        "key"
      ],
      "name": "Attach customer history",
      "right": "source:history",
      "right_key": [
        "customer_id"
      ]
    },
    {
      "id": "reasons",
      "input": "context",
      "kind": "evaluate",
      "model": "jev",
      "name": "Why might they leave?",
      "questions": {
        "explicit": {
          "instructions": "Is that reason stated explicitly in the customer's own words in `tickets` or `history`, rather than inferred?",
          "type": "noul"
        },
        "reason": {
          "criteria": {
            "competitor": "Evaluating or moving to an alternative",
            "cost": "Price or unexpected charges",
            "other": "Another reason",
            "repeated_outages": "Repeated product failures or outages",
            "unresolved_escalation": "An escalation that was not resolved"
          },
          "instructions": "Considering `tickets` and `history`, what is the most likely reason this customer would leave?",
          "type": "choice"
        }
      },
      "state_path": []
    }
  ]
}
limitsWhat it will not do
  • Text only. Transcribe calls and extract documents first.
  • Silent customers are not scored. Report how many had no tickets.
  • 0.65 is your policy. Check recall on a labelled sample.
  • No summary email. Jev does not write prose.
  • No deduplication. Two identical tickets stay two.
relatedRelated pages

Try it on your own records.