A marketing decision layer that knows when to stay silent. Built with Jev, TypeSafe AI's decision model, and jev-state.
Tacet follows the four jobs described in Uplane's post Beyond the Campaign: Building a Decision Layer for Customer Engagement: notice, remember, choose one action or none, and learn. Tacet does the counting, date math, and hard limits in code; Jev chooses among the actions Tacet allows; and a person reviews any choice Jev isn't sure about.
Why "Tacet": in sheet music, tacet means "stay silent for this part". The post argues that "the right to send nothing is what separates continuous engagement from continuous interruption", and waiting is a real action here. With raw state, 27 of the model's 35 mistakes contacted a customer who should have been left alone; with Tacet, waiting was right every time.
Open the Tacet dashboard: every test-set case side by side, raw vs jev-state, with the options Jev saw, what the hard limits blocked and why, and Jev's recorded answers. It replays saved answers, so no API key is needed.
| Scene | Accuracy, raw → jev-state | Wrong decisions executed automatically | Input tokens |
|---|---|---|---|
| 1. Next best action | 80.6% → 95.6% | 12 → 3 | 1,597 → 703 |
| 2. Budget steering | 58.3% → 100% | 3 → 0 | 1,267 → 609 |
One journey, a mobile operator's contract renewal. For each customer, the system chooses one of five actions:
| Action | Meaning |
|---|---|
service_handoff |
Hand the customer to customer service |
email_upgrade_offer |
Email the contract upgrade offer |
sms_reminder |
Text a renewal reminder |
social_coverage_proof |
Show coverage proof in paid social (no direct contact) |
wait |
Do nothing now |
Hard limits stay in code. Tacet removes actions blocked by consent or by a cap of two emails per rolling 7 days before Jev sees the question, so Jev can only choose among allowed actions. Service and waiting are always allowed.
Jev chooses among what's left, with pick() from jev-state. Below 60% confidence, the decision goes to a person instead.
Two versions of the same customer go to Jev with the same rules and options:
- Raw: the CRM record: page views with epoch timestamps, the send log, tickets, consent, device, tracking, and internal ids.
- jev-state: only the facts the rules need, as words:
renewalInterest: "present",coverageInterest: "yes",contractRenews: "in 14 to 59 days",lastUpgradeOffer: "ignored".
The post's own example (Tuesday 09:14, the third tariff-page visit in five days, two of two weekly emails used, the last offer ignored, 5G live at the address) is included. Email is blocked by the cap, SMS by consent, and the rules choose coverage proof in paid social, as in the post.
Test set: 60 seeded customers (12 per action) × 2 versions × 3 runs = 360 calls on jev-1.13.0, about $0.017 in total. Same rules, same allowed options, same model; only the state format differs.
| Raw | jev-state | |
|---|---|---|
| Accuracy | 80.6% | 95.6% |
| Sent to human review (confidence < 0.6) | 45.0% | 19.4% |
| Wrong decisions executed automatically | 12 | 3 |
| Accuracy of executed decisions | 87.9% | 97.9% |
| Customers whose answer changed across reruns | 5/60 | 1/60 |
| Avg input tokens | 1,597 | 703 |
| p50 / p95 latency | 310 / 390 ms | 305 / 368 ms |
Full tables: results/nba.md. Every call: results/nba.json.
What the raw version gets wrong. 27 of its 35 wrong answers contacted a customer who should have been left alone: the rules said wait, and raw state produced an email or an ad. Deciding to wait means counting tariff-page visits inside a 7-day window and working out the days to renewal from epoch timestamps, which is where Jev slips. With jev-state, wait was right every time. This is the "continuous interruption" the post warns about.
What jev-state still gets wrong. All 3 of its automatically executed mistakes are one customer, the same way each run: renewal in under 14 days, email allowed, and Jev chose the SMS reminder (rule 5) over the email (rule 4). Both channels had consent, so no limit was broken, but the priority order was. The same pattern showed up on the dev set: Jev follows single facts well and rule order less reliably.
How the numbers were made. The state design was tuned on a separate dev set (seed 7) only. One change came from it: rules with two conditions joined by "and"/"or" were often half-matched, so the rules now name each combined condition once ("renewal interest", "coverage interest"), for both versions. The test set (seed 42) was then run once, and those are the numbers above.
Steering ad budget toward what performs is Uplane's core product. For each ad variant, the system chooses what to do with its money:
| Action | Meaning |
|---|---|
scale |
Raise the daily budget |
hold |
Keep the budget as it is (too little data yet, or nothing to change) |
pause |
Pause the ad; it can restart later |
retire |
Stop the ad for good and replace its creative |
The rules (first match wins), each condition defined once by name:
holdwhile evidence is not enough: under 3 days, or under twice the target CPA spent.pausewhen CPA is above target: 1.3 times the target or more, or spend with no conversions.retireon fatigue: after 6+ days, the last 3 days' click rate is more than 30% below the first 3 days'.scalewhen CPA is below target: under 0.8 times the target.- Otherwise
hold.
Hard limits stay in code, as in Scene 1:
- Budget cap: an ad already at its maximum daily budget can't be scaled.
- Protected explore budget: a new "explore" ad can't be paused or retired before 5,000 impressions. This follows the idea in Uplane's post From asset production to a continuous learning system: cut every challenger early and nothing new ever gets a fair test.
Two versions of the same ad:
- Raw: what an ad platform's reporting API returns: settings, creative, targeting, ids, and one row per day (impressions, clicks, spend, conversions).
- jev-state: three facts in the rules' own words:
evidence: "enough",cpa: "below target",fatigue: "yes".
Six worked examples in src/budget/ads.ts walk through every rule with a $40 target CPA. For example, ad C spent $900 for 30 conversions ($30 each, below target), but its click rate fell from 2.0% to 1.2%, so it is retired rather than scaled.
Test set: 48 seeded ads (12 per action) × 2 versions × 3 runs = 288 calls on jev-1.13.0, about $0.011 in total.
| Raw | jev-state | |
|---|---|---|
| Accuracy | 58.3% | 100% |
| Sent to human review (confidence < 0.6) | 85.4% | 3.5% |
| Wrong decisions executed automatically | 3 | 0 |
| Accuracy of executed decisions | 85.7% | 100% |
| Ads whose answer changed across reruns | 6/48 | 0/48 |
| Avg input tokens | 1,267 | 609 |
| p50 / p95 latency | 325 / 429 ms | 315 / 405 ms |
Full tables: results/budget.md. Every call: results/budget.json.
What the raw version gets wrong. 24 of its 60 wrong answers gave more money to an ad that shouldn't get it (scale instead of hold, pause, or retire), and 10 paused an ad that should have been scaled. Spotting fatigue was the hardest: only 22% of retire cases were right, because it means comparing click rates across the first and last three daily rows. Raw answers also averaged 0.37 confidence, so 85% would go to a person: the automation would barely run.
Why jev-state reaches 100% here. Its three facts are exactly the rules' definitions, so what is left for Jev is applying an ordered list to three labels. That is the point of the design, not a sign the task is hard: the arithmetic moves into tested code, and the model does the choosing. The dev set (seed 7) needed no changes, so the test set (seed 42) ran on the design as first written.
docs/index.html is a static page that replays the test-set benchmarks. For each customer or ad it shows the raw record and the jev-state version, the options Jev saw, which actions the hard limits removed and why, and every recorded answer with its confidence, marked executed or sent to human review. It opens on the example from Uplane's post, recorded separately (3 runs each; not part of the benchmark numbers).
npm run dashboard # builds docs/data.json and serves http://localhost:3002You need Node.js 22+.
npm install
npm test # no network, no API key
npm run nba -- --dry # print what would be sent; no API calls
npm run budget:bench -- --dry # the same for Scene 2With a TypeSafe API key:
cp .env.example .env # paste your TYPESAFE_API_KEY and save
npm run nba -- --limit 5 # 5 customers × 2 versions = 10 Jev calls
npm run nba:bench -- --seed 42 --reruns 3 # Scene 1 test set: 360 calls, about $0.02
npm run budget:bench -- --seed 42 --reruns 3 # Scene 2 test set: 288 calls, about $0.01- The customers and ads are synthetic and seeded, and the rules were written for this test.
- This is not Uplane's logic or data, and it says nothing about whether Uplane uses Jev.
- jev-state does the counting and date math in code, so a comparison measures that design end to end, not better reasoning by Jev.
MIT © 2026 Suranjay Kumar
Unofficial community project. Not affiliated with or endorsed by TypeSafe AI or Uplane.