Jev Events

Safety and limits

Dry-run, protected users, trash instead of delete, review bands, budgets, rate limits, stale items and the audit log.

Automated actions fail in public. The defaults assume you would rather miss an item than time out a regular or trash a colleague's email.

safe-defaults.ts
import { logTo, monitor, recipes } from "jev-events";
import { twitch } from "@jev-events/twitch";

const mods = monitor({
  source: twitch.chat(),
  questions: { hateful: recipes.chat.hateful },
  dryRun: true, // the default: log what would happen
  budget: { inputTokensPerDay: 20_000_000 }, // ≈ $0.84 a day
  rate: { perSecond: 10 }, // requests per second, at most
  maxLagMs: 10_000, // too old to matter? skip it
})
  .on("hateful", { min: 0.9, review: 0.6 }, twitch.timeout())
  .use(logTo("moderation.jsonl")); // every answer and action

await mods.start();

Until you arm it, the log shows what would have happened:

[jev-events] [dry-run] would timeout viewer_42 for 600s (hateful p=0.97)

Dry-run until you arm it

Every monitor starts with dryRun: true. Native actions log what they would do and emit an action event with status dry-run; nothing reaches the platform. Run it on your real chat for a while, read the log, then pass dryRun: false.

Arming a monitor whose source can't act, such as twitchChat(), which reads a public chat without signing in, fails at start() with a clear error instead of silently doing nothing.

Protected users

Native actions never run on items from protected people. For Twitch that's the broadcaster, moderators, VIPs and Twitch staff. For Gmail and Google Calendar it's people at your company and people you've emailed; see Gmail. Slack's actions only reply, react and post, so no one is protected there by default. Add your own rules with protect, for example regulars or partner bots:

const mods = monitor({
  source: twitch.chat(),
  questions,
  protect: (item) => regulars.has(item.author.login),
});

protect also gets the connection, so each of your users can have their own list: protect: (item, connection) => ....

Return a string instead of true to say why, such as "regular". The action is reported as skipped with that reason, or protected user. If protect throws, the item is treated as protected.

Trash, never delete

Native actions never permanently delete your own content. Gmail's trash() moves mail to Trash, where it can be restored for 30 days, and the Google sign-in can't delete mail at all. Nothing is sent for you either: draftReply() leaves a draft. Moderation actions on other people's chat messages, such as Twitch's deleteMessage, still remove them.

Review bands

Give any policy a review threshold, and answers between review and min emit a review event instead of acting. See the review band.

Budgets

budget: { inputTokensPerDay } stops judging once that many input tokens were spent in the current UTC day. Items that arrive after that are dropped with reason budget until the next day starts. Jev's list price is $0.042 per million input tokens, so 20 million tokens is about $0.84 a day.

When the monitor reads your users' accounts, perConnection caps each one, so one busy inbox can't spend everyone's budget:

budget: { inputTokensPerDay: 200_000_000, perConnection: { inputTokensPerDay: 2_000_000 } },

Both are counted in the store, so every process that shares it, such as your site and a worker, shares the budget too. To cap several monitors in one process together, share one DailyBudget instead:

const budget = new DailyBudget(50_000_000);
const chat = monitor({ source: twitch.chat(), questions: chatQuestions, budget });
const tickets = monitor({ source: webhook(), questions: ticketQuestions, budget });

Rate limits and queues

rate on runtime() limits the requests all its monitors make to Jev together, and rate on a monitor inside it caps that monitor's share. A monitor started on its own has a limit of its own, so to run several in one process within Jev's limit, put them in one runtime().

OptionDefaultWhat it does
rate.perSecond18Sustained requests per second. Jev's published limit is 1,200 a minute.
rate.burst20Requests allowed at once after a quiet spell
rate.concurrency16Requests in flight at once
maxQueue1000Items waiting for a slot, per connection. Beyond it the oldest are dropped with reason overflow.
maxLagMs10 s for Twitch chat and Bluesky, none otherwiseItems that waited longer are dropped with reason stale instead of being acted on late
timeoutMsthe SDK'sPer-attempt timeout for a Jev request

A timeout for a message from two minutes ago confuses everyone, so for chat it's better to skip than to be late.

Floods and filters

cache: true reuses the answers for identical text for 60 seconds, per connection, so a copy-paste flood costs one request. The cache is keyed on text alone and ignores context. cache: { ttlMs, max } tunes it.

filter skips items before they are judged or cost anything. Twitch chat already skips !commands and well-known bots:

const chat = monitor({ source: twitch.chat(), questions, filter: (item) => item.text.length > 3 });

Audit log

logTo(path) appends every judgment, review, action, drop and error to a JSONL file, with probabilities, latency and token usage. Each line names the monitor and the connection. Pass { includeText: false } to keep ids and answers only.

Each record is one line in the file. Here is one, spread out to read:

{
  "at": "2026-09-24T18:03:11.482Z",
  "type": "action",
  "monitor": "twitch:chat",
  "connection": "twitch:41245072",
  "item": { "id": "b1f…", "author": "viewer_42", "text": "…" },
  "action": "twitch.timeout",
  "description": "timeout viewer_42 for 600s",
  "status": "dry-run",
  "trigger": { "event": "hateful", "question": "hateful", "probability": 0.97 }
}

Stats

monitor.stats() returns counts of items received, judged, cached, queued and dropped by reason, actions by status, errors, the connections it's running for, p50 and p95 latency, token usage and estimated spend. In a web app, jev.stats() gives the same for each monitor in the process, by monitor id.

On this page