DEV Community

Efe Genç
Efe Genç

Posted on

I open-sourced the part of a proactive AI assistant that decides when not to speak

A proactive assistant has two halves. The generating half decides what is worth saying. The suppressing half decides whether to say it now, later, or never. Almost everything written about proactive AI, and every framework I have used, is about the first half.

I have been building a proactive assistant alone since February. The second half is where most of the hard decisions ended up, and I wrote about them in The hardest part of a proactive assistant is knowing when not to speak. Several people asked for the code. Here it is, extracted and made framework-agnostic.

npm install proactive-gate
Enter fullscreen mode Exit fullscreen mode
import { createGate, defaultChecks, RedisStore } from "proactive-gate";

const gate = createGate({
  store: new RedisStore(redis),
  checks: defaultChecks({ dailyLimit: 3, quietHoursFloor: "high" }),
  onDecision: (d) => log.info("gate", d),
});

const decision = await gate.evaluate({ user, candidate });
if (decision.allowed && (await gate.commit(decision, { user, candidate }))) {
  await send(decision.surfaces, candidate.payload);
}
Enter fullscreen mode Exit fullscreen mode

Zero dependencies, TypeScript, Node 20 or newer. Source: github.com/Bubblegunn/proactive-gate; docs and a browser playground at bubblegunn.github.io/proactive-gate.

One gate, twelve checks, in order

Everything the agent might say passes through a single gate. The default order is the one that survived users:

  1. Kill switch. A production hard-stop that silences every producer at once.
  2. Consent. Before anything else, or you have evaluated preferences for someone who never agreed.
  3. Enabled on this profile.
  4. Operating mode. focus gets nothing.
  5. Global snooze.
  6. Per-type mute.
  7. Intensity. The user's setting becomes a priority floor.
  8. Quiet hours, in the user's own time zone, bypassed only above a priority floor.
  9. Trust ramp. For seven days a new user hears only high priority. The system is least calibrated exactly when the user is least forgiving.
  10. Dismissal cooldown. Three dismissals of a type in thirty days buys a week of silence for that type.
  11. Adaptive timing. Never rejects; it can move a delivery to a better moment or narrow the surfaces.
  12. Daily budget, per local day.

Every rejection carries the check that produced it and a sentence saying why. Every decision carries the full trace:

{
  allowed: false,
  rejectedBy: "quietHours",
  reason: "quiet hours 22:00 to 08:00 Europe/Istanbul; priority normal is below the floor (high)",
  trace: [
    { id: "killSwitch", outcome: "pass", ms: 0.02 },
    { id: "consent",    outcome: "pass", ms: 0.01 },
    // ...
    { id: "quietHours", outcome: "reject", reason: "quiet hours 22:00 to 08:00 …", ms: 0.09 }
  ]
}
Enter fullscreen mode Exit fullscreen mode

With one gate and a logged reason, "why was the user not told about this" has an answer. With checks scattered through a pipeline, the honest answer is "somewhere, something returned false".

The budget is enforced at send time, not at evaluate time

Two instances can both evaluate a candidate for the same user, both see four of five used, and both decide to send. The only race-safe place to enforce a cap is the atomic increment right before sending:

const decision = await gate.evaluate(input);                 // reads the counter
if (decision.allowed && await gate.commit(decision, input)) { // INCR; false on the sixth
  await send(...);
}
Enter fullscreen mode Exit fullscreen mode

RedisStore uses INCR and attaches the day's TTL on the first increment. The counter is keyed on the user's local day, so budgets reset at the user's midnight.

Fail open, on purpose

When Redis is down, the default lets the candidate through and writes outcome: "skip", reason: "check threw (…); failing open" into the trace. A cache outage should not silence every user of a product whose whole point is to speak up. If your product would rather stay silent, onStoreError: "closed" turns the same failure into a rejection that names the check.

Replay a day before you ship a policy

Nothing to install and nothing to write: npx proactive-gate simulate replays a generated week through the default order and through no gate at all, and prints what each policy did with every candidate. Point --events at a JSONL file of your own and it answers the same question about your traffic.

The examples below live in the repository rather than in the package, so they are git clone and then:

npx proactive-gate replay examples/day.jsonl --commit
Enter fullscreen mode Exit fullscreen mode
17 candidates  ·  7 allowed (41.2%)  ·  10 rejected

check         stopped  example
---------------------------------------------------------------
intensity           3  priority low is below the "normal" intensity floor (normal)
consent             3  user has not consented to proactive behaviour
mode                2  operating mode "focus" does not allow proactive messages
quietHours          1  quiet hours 22:00 to 08:00 (thu 2026-09-03) Europe/Istanbul; priority normal is below the floor (critical)
dailyBudget         1  daily budget of 5 used (5)
Enter fullscreen mode Exit fullscreen mode

Feed it a week of real candidates and a proposed policy, and you know the allow rate and the silence reasons before a single user does.

A policy is a JSON file

The checks above are functions, and a policy made of functions cannot be diffed in a pull request, replayed by someone who does not run Node, or handed to a second implementation. So a policy is also data: createGate({ policy }) takes a JSON file with a specVersion and an ordered list of check entries, and replay --policy policy.json runs the same file from the command line. The functions stay as the escape hatch for checks the schema does not know. The same day of candidates through the repository's example policy, which caps the day at three:

npx proactive-gate replay examples/day.jsonl --policy examples/policy.json --commit
Enter fullscreen mode Exit fullscreen mode
17 candidates  ·  5 allowed (29.4%)  ·  12 rejected

check         stopped  example
---------------------------------------------------------------
intensity           3  priority low is below the "normal" intensity floor (normal)
dailyBudget         3  daily budget of 3 used (3)
consent             3  user has not consented to proactive behaviour
mode                2  operating mode "focus" does not allow proactive messages
quietHours          1  quiet hours 22:00 to 08:00 (thu 2026-09-03) Europe/Istanbul; priority normal is below the floor (high)
Enter fullscreen mode Exit fullscreen mode

Presets for the rules you did not write

Most of the limits a product has to respect were written by a platform or a legislator. proactive-gate/presets carries seventeen of them as ordered check lists, each with the pages its numbers come from and a note on what it leaves out: LINE's monthly push budget by plan, WeChat's subscription, customer-service and template message rules, WeCom's per-member rate, Kakao AlimTalk and brand messages (08:00 to 20:50 Asia/Seoul), Korea's Network Act night-consent window, Japan's anti-spam opt-in, China's minor mode, India's TCCCPR opt-in time bands, Brazil's LGPD marketing consent, the US TCPA calling hours (08:00 to 21:00 at the user's local time), the EU ePrivacy soft opt-in, the WhatsApp Business messaging limits, and the Telegram and Slack rate limits.

import { presets } from "proactive-gate/presets";
const gate = createGate({ checks: [checks.consent(), ...presets.kakaoBrandMessage()] });
Enter fullscreen mode Exit fullscreen mode

They are reviewable defaults, not legal advice. Several official sources disagree with each other, and the note on each preset says which value was chosen and why.

Where it plugs in

Adapters on subpaths for the Vercel AI SDK, Mastra, LangChain and OpenAI Agents wrap the tool or step that would reach the user, and deny with the gate's reason. A proactive-gate hook command answers Claude Code's PreToolUse hook protocol, so a coding agent's own outreach tools pass through the same gate.

The same gate exists in Python, in python/ of the same repository, with a sync Gate and an AsyncGate over redis.asyncio. It is a sibling rather than a port: spec/SPEC.md states the behaviour as numbered requirements, and 57 language-neutral fixtures under spec/fixtures (atomic commit, the ISO week, deferral, shadow mode, and a clock/ area for the days a clock misbehaves) run through both implementations in CI. A third implementation starts from the fixtures, not from the source, and the specification is tagged separately from the package so an implementation can pin the contract without depending on npm or PyPI.

Four people I had never met have sent thirteen pull requests. @Aaqibhafeezkhan wrote the SQLite store, then came back and turned the store tests into a suite you can run against a store of your own (proactive-gate/store-contract). @edwardsong08 added the weekly budget. @shivam-070208 added Markdown output to a sibling tool. @LouisDeconinck sent six in one day, including the sentence renderer that turns a decision into something a product manager can read.

The thirteenth is the one I did not expect, and it is the reason I would recommend publishing a conformance suite to anyone shipping two implementations of the same thing. He wrote an adversarial clock suite: twenty-one fixtures for the days a clock misbehaves, deleted and repeated daylight saving hours, 23 and 25 hour local days, a mid-week timezone move, Apia's skipped calendar day, weeks whose ISO year is not the calendar year, and years below 1000. Seventeen of them agreed across both implementations, which was the point of running it. Two did not, and rather than adjust his own fixtures to pass he filed the bugs against my code and declared the failures.

They were real. Below year 1000 the TypeScript side wrote a local day of 1-06-01 instead of 0001-06-01, and Date.UTC reads years 0 to 99 as offsets from 1900, so the weekly budget key came out as the literal string NaN-WNaN and the monthly key stopped being a month at all, which meant the monthly cap silently never bound. The Python side was correct about the month and wrong about the week format. Installing both published packages side by side and asking them for the same three keys is what made it obvious:

counter    TypeScript 0.7.0          Python 0.7.0
daily      budget:u:1-06-01          budget:u:0001-06-01
weekly     weeklyBudget:u:NaN-WNaN   weeklyBudget:u:1-W22
monthly    monthlyBudget:u:1-06-01   monthlyBudget:u:0001-06
Enter fullscreen mode Exit fullscreen mode

Three counters, three disagreements. One store serving both siblings was keeping two sets of counters and neither side could tell. All of it is fixed in 0.7.1 and the fixtures that found it are in the published package, so you can run them against your own implementation.

What it does not do

It does not decide what is worth saying. It does not estimate value or attention on its own: the optional utilityFloor check applies Horvitz's expected-utility threshold and boundedDeferral moves a delivery when the user is busy, but the probability and the costs come from your model, and both checks skip when you do not supply them. adaptiveTiming stays a hook for your own model of the user's next good moment. It does not coordinate across products: three agents that each respect a budget of three still add up to nine. Tian Pan's notification budget essay makes the product case for all of this and calls the cross-agent layer the open problem. I agree, and I have not solved it.

If you have shipped an agent that reaches out to people, I would like to know what you ended up gating on, and which of these twelve you would remove.

Top comments (0)