Every app makes small decisions about the people using it. Should this subscriber see an offer before they cancel? Is this visitor in the middle of something, or is now a good time to ask a question? Which support queue should this chat go to? Most apps answer these with a rule someone wrote years ago, or don’t ask them at all.
Our last post covered the new models built for these calls, and why the state you send them matters more than which one you pick. This one covers where they fit in an app, how to feed them what your users are doing right now, and what tends to go wrong after launch.
Decisions rules never got right
A rule works when the condition is something you can count. “Show the banner after the third visit” is easy to write and easy to check. Whether someone is stuck, comparing products, or about to leave isn’t a number. Rules approximate it with proxies, such as time on page or the number of errors, and the proxies pile up as people find cases they miss.
A trained model can learn the difference, but only from labels. Someone has to mark thousands of sessions as “stuck” or “not stuck”, and when the product changes the question, the labeling starts over. A large language model needs no labels, but it is usually too slow and too costly to call on every page view.
A System One model, such as TypeSafe’s Jev, sits between those. You write the question and the allowed answers in plain language, send a state object, and get back a probability for each answer in a fraction of a second. To change the question, you edit a string.
- Rules: Counts and thresholds; Cheap enough for every page view. Breaks on anything you can't count.
- Trained model: Calls about what the user is doing; Cheap enough for every page view. Needs labels, and new ones when the question changes.
- System One model: Calls about what the user is doing; Cheap enough for every page view. Write the question and the answers, no labels.
- Large language model: Calls about what the user is doing; Too slow or costly for every page view. Needs no labels, but each answer is slow and costly.
Thresholds and counts still belong in code. Where you have labels and a question that stays the same, a trained model is hard to beat. For everything else, ask whether a person could make the call in a few seconds by looking at what the user just did. If they could, a System One model probably can too.
Six decisions worth asking a System One model
Each of these is a question about one person, asked at a specific moment. They are illustrations, but apps already make every one of these calls with rules today. The cards show when the question is asked, the allowed answers, and what the state needs. Attributes are counts and values that Signals keeps current for each user, and the agentic context is a short list of their recent events.
On a product page
What is this shopper trying to do?
- browsing
- comparing
- ready_to_buy
- Attributes
- Items in cart, searches this session
- Agentic context
- The products they just viewed
When a reader opens an article
Which wall should they see?
- registration
- subscribe
- none
- Attributes
- Articles read this month, newsletter signup
- Agentic context
- The topics they read today
On the cancel page
What would keep this subscriber?
- pause
- downgrade
- help
- none
- Attributes
- Months subscribed, features used this month
- Agentic context
- Errors and pages just before they clicked cancel
Before a survey or chat prompt
Is this visitor in the middle of a task?
- yes
- no
- Attributes
- Prompts already shown this session
- Agentic context
- Form fields filled, scrolling, idle time
When a support chat opens
Which queue, and how urgent?
- billing
- technical
- account
- urgency 1 to 5
- Attributes
- Plan, past tickets, failed payments
- Agentic context
- Pages viewed and errors hit before the chat
After key events in a trial
Is this account ready for sales?
- sales
- onboarding
- self_serve
- Attributes
- Teammates invited, active days, features adopted
- Agentic context
- API keys created, pricing and docs pages read
The state decides whether the answer is any good
Each of those calls depends on when you ask, what you ask, and the state you send. The state needs the most care. TypeSafe’s notes on Jev’s weak spots say it doesn’t count reliably, reads dates as text, and loses accuracy as the state fills with content the question doesn’t need. A raw clickstream runs into all three. A session can hold thousands of events, each with a timestamp, and most of them are page pings and impressions.
Signals builds a state from the session that avoids those weak spots.
- Attributes do the arithmetic. Signals computes counts, recency and last values from each event as it arrives, so the model reads “used two parts of the product this month” and never has to count.
- The agentic context keeps the order. It is a short list of the recent events a decision cares about, with only the properties you name. The model can see that a failed export came right before the billing page, and no count would show that.
01The session
- page_view Reports (kept)
- page_ping
- banner_impression
- export_clicked
- export_failed csv (kept)
- page_ping
- export_clicked
- export_failed csv (kept)
- page_ping
- banner_impression
- page_view Billing (kept)
- page_ping
- cancel_clicked (kept)
Most events say little about this decision.
02The state Signals serves
Attributes
- plan
- team
- months_subscribed
- 7 to 12
- features_used_this_month
- 2
- failed_payments
- 0
Agentic context
- page_view Reports
- export_failed csv
- export_failed csv
- page_view Billing
- cancel_clicked
03The answer
What would keep this subscriber?
Below the threshold, the page keeps its default.
The model reads a short state instead of the whole session. Your code gets back a probability for each answer and can check it before acting.
Building the call in Node
In Node, the Signals SDK reads both parts of the state and the TypeSafe SDK asks the question. Here is a short version of the cancel-page call.
import { Signals } from "@snowplow/signals-node";
import { choice, TypeSafeClient } from "@typesafe-ai/sdk";
const signals = new Signals({ baseUrl, apiKey, apiKeyId, organizationId });
const typesafe = new TypeSafeClient();
export async function cancelOffer(userId: string, sessionId: string) {
const [subscriber, recent] = await Promise.all([
signals.getServiceAttributes({
name: "subscriber",
attribute_key: "user_id",
identifier: userId,
}),
signals.getAgenticContext({
name: "recent_activity",
identifier: sessionId,
format: "json",
}),
]);
const { answers } = await typesafe.systemOne({
model: "jev-1.13.0",
state: { subscriber, recent_activity: recent.events },
questions: {
offer: choice("What would most likely keep this subscriber?", {
pause: "They still value the product but have little use for it right now.",
downgrade: "They use a few features and the price is the problem.",
help: "Something is broken for them, such as repeated errors before canceling.",
none: "They have moved on and an offer would only slow them down.",
}),
},
});
const { choice: offer, confidence } = answers.offer;
return confidence >= 0.7 ? offer : "none";
}
Keep the last line. When the confidence is low, showing the default page is safer than acting on a guess.
Your coding agent can add the Signals side for you. Run
npx plugins add snowplow/skills, then ask it to add the Node SDK, define the attributes and the agentic context the call needs, and pass them as the state. The docs cover attributes and agentic contexts.
What goes wrong once it ships
Writing the call is quick, and it will look right on the handful of states you try it with. The trouble starts when real visitors hit it, and none of it shows up as an error in your logs. These are the problems we would watch for. You can find most of them before launch by running the call on past sessions, but you need a live experiment to learn whether acting on the answer helps.
Two answers that mean the same thing
If pause and downgrade both fit a subscriber who barely uses the product, the probability splits between them and neither reaches 0.7.
Find it before launch
Sure of itself with little to go on
A subscriber who clicked cancel straight from the home page can get an answer as confident as one with a week of failed exports behind them.
Find it before launch
Tested only on tidy examples
The states you type into a test don't have the duplicate page views, missing fields and double-firing tags that real sessions do.
Find it before launch
Asked at the wrong moment
On the first page view there is little to go on, and by the cancel click it may be too late to help.
Find it before launch
Answers that change without a deploy
A new Jev release or a renamed event can shift your answers while your code stays the same.
Find it before you upgrade
Not knowing whether it helped
Showing an offer changes what the subscriber does next, so past sessions can't tell you whether the offer worked.
Needs a live experiment
TypeSafe’s own notes say Jev answers the question you wrote, not the one you meant. Two answers that read differently to you can look the same to the model, and you usually only notice after going through a lot of real answers. A high confidence doesn’t mean the answer is right, either. How closely confidence tracks accuracy varies between models and datasets, which is why a study of Jev on police crash reports recommends auditing each model separately.
To try it on your own app, start free. The free tier runs on the real engine, needs no card, and shows an attribute updating against your own traffic in the first session. The System One models page shows more decisions built on live behavior.