jevmod (npm)

September 18, 2026 ยท View on GitHub

Moderation for user text, powered by Jev (TypeSafe's System One model). Every message gets a probability for spam, scam, harassment, adult content, off-topic, self-harm, doxxing and sexual content involving minors, plus for rules you write in plain language. You own the thresholds and the actions; nothing is deleted until you turn that on.

This is the TypeScript port of the Python package in this repository. It asks Jev the same questions (categories.json is shared byte for byte), applies the same policy and returns the same decision shape.

Install

npm install jevmod

Node 20 or newer (the TypeSafe SDK requires it). ESM and CommonJS builds with type declarations are included. The only runtime dependency is @typesafe-ai/sdk.

Usage

import { Moderator, Policy } from "jevmod";

const mod = new Moderator(); // TYPESAFE_API_KEY from the environment
const d = await mod.check("FREE NITRO for the first 100!! claim at discord-gifts.ru/nitro", { channelTopic: "gaming" });
d.action; d.category; d.probability; // "flag", "scam", 0.97
d.scores; // { spam: 0.95, scam: 0.97, harassment: 0.03, ... }

const policy = new Policy();
policy.setCategory("scam", "delete", 0.7);
policy.setRule("no_politics", "No political discussion. Game news is fine.", "flag", 0.8);
const decisions = await new Moderator({ policy }).checkMany(["...", "...", "..."], { channelTopic: "support" });

checkMany is the cheap path: every message that passes the pre-filter goes to Jev in one request. Messages under eight letters without a link, trusted authors (authorTrusted: true) and repeats of already-judged text are never sent. Only the message text and the channel topic reach TypeSafe; author names and ids do not.

A decision looks like this, the same as the Python Decision.to_dict() and the HTTP API:

{"message_id": "0", "action": "flag", "category": "scam", "probability": 0.97,
 "scores": {"spam": 0.95, "scam": 0.97, "harassment": 0.03, "nsfw": 0.01, "selfharm": 0.0, "doxxing": 0.0, "minors": 0.0},
 "judged": true, "reason": "jev", "policy_version": 1}

action is none, flag, delete or timeout. category is the winning category or rule:<name>. reason is jev, cache, or why the pre-filter skipped it (too short, empty, trusted author).

Policy

Defaults: every category flags except offtopic, which is off (it needs a channelTopic to be useful). Thresholds: spam 0.85, scam 0.75, harassment 0.75, nsfw 0.8, offtopic 0.9, selfharm 0.8, doxxing 0.8, minors 0.7. Custom rules default to flag at 0.8; at most five per policy. policy.nudge("spam") raises a threshold by 0.03 (false-positive feedback). JSON.stringify(policy) and Policy.fromJSON(obj) use the same shape as the Python package and GET/PUT /v1/policy, so a policy can move between the three.

Lower level

Judge does the batching, caching and the single Jev request (judge(messages, categories, customRules) returns Verdicts with raw probabilities; counters requests, inputTokens, judgedMessages). decide(policy, verdict) turns a verdict into a decision. normalize and prefilter are exported for tests and tooling.

API key

The TypeSafe key comes from the TYPESAFE_API_KEY environment variable or from the constructor:

new Moderator({ apiKey: process.env.MY_SECRET_STORE_KEY });

It is passed straight to the TypeSafe SDK and is never logged, never included in error messages, and never written to disk by this package. Keep it out of git: put it in your process environment or a .env file that is ignored. The SDK's debug log level prints request bodies (message text), not credentials; the default level is warn.

HTTP client for a deployed jevmod API

If you run the Python HTTP API (jevmod api or the Docker image) so that only the server holds the TypeSafe key, callers use a tenant key minted with POST /v1/keys and this client:

import { JevmodClient } from "jevmod";

const api = new JevmodClient({ baseUrl: "https://mod.example.com", apiKey: process.env.JEVMOD_API_KEY });
const res = await api.moderate(["hello there", { id: "b", text: "DM me to double your ETH", channel_topic: "gaming" }]);
res.decisions; // same decision objects, without policy_version
await api.getPolicy();
await api.putPolicy({ actions: { scam: "delete" }, thresholds: { scam: 0.7 }, rules: { no_politics: "No politics." } });
await api.decisions(50); // the tenant's recent decisions

baseUrl and apiKey fall back to JEVMOD_API_URL and JEVMOD_API_KEY. Failures throw JevmodApiError with the HTTP status and the response body; the key is not part of the message. Uses the global fetch.

Examples

  • examples/node.mjs: check, checkMany, a custom policy, the HTTP client.
  • examples/express.mjs: an Express middleware that moderates a body field, rejects delete/timeout decisions with 422, lets staff through, and fails open when Jev is unreachable.

Build first (npm run build), then TYPESAFE_API_KEY=... node examples/node.mjs. Express is a dev dependency of this package only for the example.

Shared questions

The questions Jev is asked live in categories.json at the root of the Python package (jevmod/categories.json). This package ships a verbatim copy in src/categories.json; npm run sync-categories refreshes it and a test fails if the two files differ. Do not edit the wording here: every implementation (Python, npm, MCP) must ask Jev exactly the same thing so their numbers are comparable.

Two details of the port worth knowing: text is normalised with the same rules as Python (HTML entities, NFKC, combining marks removed using a table generated from Python's unicodedata, format characters removed), and the cache key is the same SHA-256 prefix, so verdicts and keys line up across the two packages. htmlUnescape covers numeric entities and the common named ones, not the full HTML5 table.

Development

npm install
npm run typecheck   # tsc --strict
npm run build       # tsup: dist/index.js (ESM), dist/index.cjs, .d.ts
npm test            # vitest; live tests run only when TYPESAFE_API_KEY is set

Live tests call the real Jev API on the same sample messages as the Python test suite. They are skipped, not mocked, without a key.