@framers/agentos-ext-ml-classifiers
A content classifier guardrail for @framers/agentos: one local English toxicity model run through ONNX, with an LLM judge or keyword patterns when that model is not available.
What it does
The guardrail scores text in four categories, toxic, injection, nsfw and threat, and returns FLAG or BLOCK when a score passes its threshold. It checks user input and the agent's final response; it does not read streaming chunks. The pack also registers a classify_content tool that returns the same scores on demand.
Each text goes to the first tier that answers:
- ONNX. The guardrail imports
@huggingface/transformers(an optional dependency) and loads one model,Xenova/toxic-bert, the ONNX conversion ofunitary/toxic-bert, withpipeline('text-classification', 'Xenova/toxic-bert', { device: 'cpu' }). The load is tried once per guardrail. When the package is missing or the model fails to load, the guardrail writes one warning with the error and the tier it uses instead (see Logging), and goes to the next tier. - LLM judge. When you pass an
llmInvokercallback, the guardrail asks the model for a JSON verdict on all four categories. A failed call, or a reply with every score at zero, goes to the next tier. - Keywords. Regular expressions per category, always available.
What the ONNX model scores
toxic-bert has six labels. The guardrail maps them to its categories and keeps the highest score per category:
| Model label | Category |
|---|---|
toxic, severe_toxic, insult, identity_hate | toxic |
obscene | nsfw |
threat | threat |
nsfwfrom the ONNX tier is the model'sobscenelabel, a score for obscene language. It is not a sexual-content classifier.injectionis never scored by the ONNX tier: it stays at 0 there. Only the LLM judge and the keyword patterns score it.- The model is for English text: its card lists its training data as the Wikipedia comments of the Jigsaw Toxic Comment Classification Challenge, an English dataset, and offers a separate multilingual model for other languages.
The model's card states its main limit (unitary/toxic-bert):
If words that are associated with swearing, insults or profanity are present in a comment, it is likely that it will be classified as toxic, regardless of the tone or the intent of the author e.g. humorous/self-deprecating. This could present some biases towards already vulnerable minority groups.
Install
npm install @framers/agentos-ext-ml-classifiers
Peer dependency: @framers/agentos. @huggingface/transformers is an optional dependency; without it the guardrail runs the LLM judge or the keyword tier.
Quickstart
import { AgentOS } from '@framers/agentos';
import { createMLClassifierGuardrail } from '@framers/agentos-ext-ml-classifiers';
const agentos = new AgentOS();
await agentos.initialize({
extensionManifest: {
packs: [
{
factory: () =>
createMLClassifierGuardrail({
categories: ['toxic', 'injection', 'threat'],
flagThreshold: 0.5,
blockThreshold: 0.8,
thresholds: { toxic: { flag: 0.6, block: 0.9 } },
// Optional: an LLM judge for when the ONNX tier is unavailable.
llmInvoker: async (systemPrompt, userMessage) => callYourModel(systemPrompt, userMessage),
}),
enabled: true,
},
],
},
});
A score above flagThreshold (default 0.5) flags; above blockThreshold (default 0.8) blocks. thresholds overrides either per category.
Public API
createMLClassifierGuardrail(options?, logger?)returns anExtensionPackwith the guardrail and theclassify_contenttoolcreateExtensionPack(context)is the factory AgentOS's extension loader calls; it readscontext.optionsandcontext.loggercreateMLClassifierPackis a deprecated alias ofcreateMLClassifierGuardrail
See src/types.ts for MLClassifierOptions, LlmInvoker and MLClassifierLogger.
Moderation: @framers/agentos-ext-ml-classifiers/moderation
A second entry point for content people send each other: an OpenAI Moderation guard used as a signal, an OpenAI Moderation check for AgentOS's image guard, and a conduct classifier on a judge model you choose. Nothing in it loads a local model.
import { PhraseListGuardrail, StaticPhraseListSource, moderateContent } from '@framers/agentos';
import {
InMemoryConductRefusalCache, createAgentOSConductJudge, createConductGuardrail, createOpenAIModerationGuardrail,
} from '@framers/agentos-ext-ml-classifiers/moderation';
// a sequential phrase list runs first: a text it blocks reaches neither vendor
const hardBan = await PhraseListGuardrail.create({
id: 'hard-ban', sequential: true,
source: new StaticPhraseListSource({ version: '1', reviewedAt: '2026-11-12', reviewedBy: 'team', entries: [{ phrase: 'banned phrase', match: 'word', onMatch: 'block', ruleId: 'BAN_1' }] }),
});
const openai = createOpenAIModerationGuardrail({ apiKey: process.env.OPENAI_API_KEY!, logger });
const conduct = createConductGuardrail({
judge: createAgentOSConductJudge({ provider: 'anthropic', model: 'claude-haiku-5-5' }),
fallbackJudge: createAgentOSConductJudge({ provider: 'openai', model: '<a second vendor model>', thinkingHeadroomTokens: 1024 }),
attemptTimeoutMs: 1_800,
cache: new InMemoryConductRefusalCache(),
logger,
});
const decision = await moderateContent({ text }, {
guards: [hardBan, openai, conduct],
context: { userId, sessionId, moderation: { surface: 'public_room', roomId, rating: 'standard' } },
stopVendorsOn: ['MINOR_SEXUAL_SIGNAL'],
});
// decision.outcome: 'allow' | 'flag' | 'refuse' | 'hold'; decision.unavailable is true when only an outage decided
Needs @framers/agentos 0.17.1 or newer, the release with moderateContent(), holds and frameUntrustedEvidence(). On an older core the subpath fails when it is linked. The root entry is unchanged and keeps working on every core its peer range names.
Both entry points' type declarations use .js specifiers, so they resolve under TypeScript's NodeNext module resolution as well as bundler: Node's ESM resolution, which NodeNext enforces, "requires relative paths to include file extensions" (TypeScript's module theory). The test suite packs the built package and type-checks a NodeNext consumer against it, skipLibCheck off.
The OpenAI Moderation guard
createOpenAIModerationGuardrail(options) sends the input's text, and nothing else, to POST /v1/moderations (model omni-moderation-latest by default; CreateModerationRequest and CreateModerationResponse in OpenAI's OpenAPI file). It reads the first result by walking its own list of thirteen categories (OPENAI_MODERATION_CATEGORIES): a key outside the list is ignored, and a value of another type makes the answer unusable. So does a result whose categories or category_scores object is missing or null, and so does one that leaves out a category's boolean or score: OpenAI's schema lists all thirteen in both objects, so an answer with empty or incomplete maps judged nothing and is never read as a clean one. A closed guard refuses it as an outage. One exception: the schema declares the booleans of illicit and illicit/violent nullable (OpenAI's OpenAPI file, CreateModerationResponse). A null boolean or a null score on those two reads as false with no score, and the other eleven decide; their keys must still be there. A null on any other category makes the answer unusable. The model named in a verdict is the one the guard was built with, never the answer's.
- Rules. Each category has a rule
{ flagAt?, holdAt?, blockAt?, reviewCode? }. A number is a score threshold from 0 to 1, met when the score is at or above it;'vendor'means OpenAI's own boolean for the category. A block outranks a hold and a hold a flag. Any other threshold (NaN,Infinity, a number above 1 or below 0, a string) is aTypeErrorwhen the guard or the image check is built; its message names the option, never the value. An empty rule ({}) is valid and takes the category's rule away. - Defaults. Every category flags at
'vendor';sexual/minorsalso holds at'vendor'with review codeMINOR_SEXUAL_SIGNAL. Nothing blocks: OpenAI's scores refuse no message unless you set ablockAt. - Your rules. A rule you pass for a category replaces the library's rule for that category whole. A rule you pass for
'sexual/minors'takes its hold away unless it names aholdAtitself. A hold whose rule names noreviewCodeis held under the category's own code. - Reason codes.
OPENAI_MODERATION_and the category in upper case,/and-written_(OPENAI_MODERATION_SEXUAL_MINORS), plus the rules' review codes. - Down. A network error, a non-2xx answer, a timeout (
timeoutMs, default 3000, which bounds the whole request: the guard stops waiting for anapiKeyfunction at that point, and nothing is sent) or an answer of another shape skips the guard with onemoderation.signal_skippedline, and the other guards decide.failClosed: truemakes it an outage instead. The body of an error answer is never read; it is cancelled at once, so its connection is freed. A successful answer is read up to 1 MiB (MAX_MODERATION_ANSWER_BYTESin the source; an answer for one input is a few kilobytes): aContent-Lengthpast it is refused before the body is read, and a body that runs past it is cancelled there. Either is an answer of another shape. - Phase. It is a parallel (phase 2) guard and reads the call's
vendorSignalbefore its request, and again once its key is resolved (anapiKeyfunction may take a while): once a code instopVendorsOnhas aborted it, the guard sends nothing. It reads the dispatcher'sdeadlineSignalat the same two points, the second right before the request: once the dispatcher has stopped waiting for the guard (its deadline, or a shorter one a required posture gives it), the text is sent nowhere.
The image check
openAIModerationImageCheck(options) is a classify check for AgentOS's image guard, ImageGuardrail, which needs @framers/agentos 0.17.1 or newer. It takes the OpenAI guard's options except stages and failClosed; its default id is openai-moderation-image.
- What it sends. One image per request, always as a data URL of the bytes the image guard captured (
data:<mime type>;base64,...), never a URL. For animage_urlinput the guard fetches the image once through yourloadImage, and OpenAI judges those bytes, the same ones a hash check saw. - What it reads. The six categories OpenAI applies to images:
self-harm,self-harm/intent,self-harm/instructions,sexual,violenceandviolence/graphic(category_applied_input_typesinCreateModerationResponse, OpenAI's OpenAPI file). The seven others apply to text alone: the check reads none of them, so one left out or set null (the schema declaresillicitandillicit/violentnullable) leaves the image's judgment as it is, and a rule you pass for one of them is not read. An answer that leaves out the boolean or the score of one of the six, empty maps included, judged nothing: it counts as an answer of another shape (see Down), never as a clean image. Its reason codes are the six categories' codes and your rules' review codes. - Defaults. Every image category flags at OpenAI's own boolean. Nothing holds and nothing blocks: OpenAI's scores refuse no image unless you set a
holdAtor ablockAtinrules, which work per category as they do on the OpenAI guard. A flag carries the six categories' scores, a sub-category written with.(violence.graphic), since the image guard keeps a score only under a key of letters, digits and_ . : -. - Down. It is built with
onError: 'skip'. A network error, a non-2xx answer, a timeout (timeoutMs, default 3000, which bounds the whole check: the check stops waiting for anapiKeyfunction at that point, nothing is sent, and the image's data URL is made only once the key is resolved) or an answer of another shape (an answer past 1 MiB included, as on the OpenAI guard) is written by the image guard as oneimage_guard.check_failedline, and your otherclassifychecks decide the image. When none of them answered for it, the image guard refuses the image as unavailable (IMAGE_CHECK_UNAVAILABLE). The check writes no line of its own. - Stopping. The image guard reads the call's
vendorSignalbefore eachclassifycheck; the check reads it again once its key is resolved, and then sends nothing and counts as a check that did not answer. A request already sent is answered as usual. - Deadlines. With
@framers/agentos0.17.1 the image guard's deadline does not reach a check: the guard answers at its owntimeoutMs(default 15,000), or at the shorter deadline a required posture gives it, while the check runs until its owntimeoutMs(default 3,000). So set the check'stimeoutMsat or below the guard's. If it is longer, a request may be sent after the guard has answered, and its answer is discarded. The check'stimeoutMsbounds the whole check, the key lookup included: nothing is sent after it. A core that hands the check adeadlineSignal, on the context or on the image, stops the check at that deadline as well: once it is aborted nothing is sent, and a request in flight is cancelled.
import { ImageGuardrail, moderateContent } from '@framers/agentos';
import { openAIModerationImageCheck } from '@framers/agentos-ext-ml-classifiers/moderation';
const images = ImageGuardrail.create({
id: 'images',
requireMatch: true,
checks: [
hashCheck, // your hash service: { id, kind: 'match', check }
openAIModerationImageCheck({ apiKey: process.env.OPENAI_API_KEY! }),
visionJudge, // your own vision model: { id, kind: 'classify', check }
],
loadImage, // fetches an image_url input once, under your own egress rules
});
const decision = await moderateContent({ images: uploads }, { guards: [images], context: { userId, sessionId } });
The conduct classifier
createConductGuardrail(options) (and ConductClassifier, which it wraps) asks a judge model for one JSON object of confidences over ten categories (CONDUCT_CATEGORIES: minor_sexual, suspected_minor, threat, sexual, violence, hate, harassment, self_harm, scam_spam, personal_data), in the form {"c":{"<category>":<confidence>}}. The judge writes no reason and no decision; code decides.
- Policy.
policyis{ version, sharedPrefix, ratings, defaultRating }.sharedPrefixholds your category definitions and the rules of every rating. It goes in one system block after the library's output contract andUNTRUSTED_EVIDENCE_NOTICE, with a cache breakpoint at its end, so every call shares one cached prefix. The user turn holdsRating: <rating>and the message framed byframeUntrustedEvidence(). A call's rating iscontext.moderation.ratingwhen the policy lists it, elsedefaultRating(oneconduct.rating_unknownline per unknown rating word).DEFAULT_CONDUCT_POLICYis plain and generic, with one rating,standard. - Thresholds.
thresholdsis keyed by rating, with'*'for every rating; each entry is{ flagAt?, holdAt?, refuseAt? }per category. Yours are laid over the library's category by category, so a category you do not name keeps the library's numbers. A category you name takes your entry whole, all three numbers:{ '*': { minor_sexual: { refuseAt: 0.9 } } }leavesminor_sexualwith no hold, so name every number you mean to keep. The library's: a refusal at 0.8 on every category butsuspected_minorandself_harm; a hold at 0.5 onminor_sexual,suspected_minorandthreat; a flag at 0.5 on the rest;self_harmonly flags. Refuse outranks hold, hold outranks flag, and among categories that meet the same threshold the one with the highest confidence decides. - Verdicts. A refusal is a BLOCK
CONDUCT_<CATEGORY>. A hold is a BLOCK withhold: { reviewCode: '<CATEGORY>' }and reason codeCONDUCT_<CATEGORY>. A flag is a FLAG.metadataholds the categories found, the deciding one, the rating, the policy version, the route (primary,fallbackorcache) and the number of attempts. - Strict answers. After white space and one enclosing code fence are removed, the answer must be exactly the contract: one object, the key
calone, categories among the ten, confidences from 0 to 1. Anything else, a sentence before the object included, is a failed attempt and never read as nothing found. So is an answer that stopped at the output cap, whether or not its text parses. - The chain.
judgeis tried1 + retriestimes (retriesdefault 1), thenfallbackJudgeonce. Each attempt hasattemptTimeoutMs(default 3000). With no usable answer the guard writesconduct.unavailableand throwsConductUnavailableError. It is closed by default (failClosed: true), so AgentOS's dispatcher answers the outage itself: a BLOCKGUARDRAIL_ERRORthat names the guard (metadata.guardrailId), whichmoderateContent()reports asunavailable: trueand ranks below another guard's refusal or hold. WithfailClosed: falsethe guard answers nothing. The guard's own deadline defaults to room for every attempt,(1 + retries + (fallbackJudge ? 1 : 0)) * attemptTimeoutMs + 500: with one retry, a second route and 1,800 ms per attempt, 5.9 seconds. The half second covers the cache read. Once the dispatcher stops waiting for the guard (thedeadlineSignalAgentOS hands it), no further attempt starts, so a text is not sent to the second route for an answer nobody reads. stopVendorsOn. Once the call'svendorSignalis aborted (OpenAI'sMINOR_SEXUAL_SIGNALhold withstopVendorsOn: ['MINOR_SEXUAL_SIGNAL']), no further attempt starts. An attempt already sent is still read; with no usable answer the guard writesconduct.stoppedand fails as above, and the hold that stopped it decides.- Length. A message over
maxMessageChars(default 8,000) is refused withCONDUCT_TOO_LONGand no model call: a cut text would leave a part nobody read. The guard applies the limit;ConductClassifier.classify(), called on its own, does not. - Numbers.
retriesis a whole number, 0 or more.attemptTimeoutMsandtimeoutMs, here and on the OpenAI guard, are milliseconds above 0 and at most 2,147,483,647, the longest a Node timer waits; a longer one runs after 1 ms (Node's timers). A deadline the library works out is held to the same limit: this guard's defaulttimeoutMs(the sum above) and the OpenAI guard's dispatcher deadline (itstimeoutMsplus half a second).maxMessageCharsis above 0 (Infinitysets no limit).cacheTtlMsis above 0 and finite, andInMemoryConductRefusalCache'smaxEntriesis a whole number, 1 or more. Any other value, theNaNthatNumber(process.env.NAME)gives for a name that is not set included, throws aTypeErrorwhen the guard or the cache is built.
The refusal cache
cache stores refusals alone, never an allow, a flag or a hold, for cacheTtlMs (default ten minutes). The key is a SHA-256 of the policy version, the rating, that rating's thresholds and the text exactly as received: no case folding, no Unicode normalisation, no look-alike mapping, because two texts that differ by one letter can name two different things. A hit answers with route: 'cache' and no model call. The key is unsalted, a fingerprint a guessed text can be matched to, so a shared store holds message data for the entry's lifetime. InMemoryConductRefusalCache (10,000 entries by default, oldest out first) serves one process; implement ConductRefusalCache (get, set) over a shared store when you run more than one. A read that throws, or that has not answered within 250 ms, is skipped with one conduct.cache_failed line, and the judge is asked. A write is not waited for: the refusal is returned at once, and a write that fails writes the same line when it fails. The entry stored is a copy, so an edit of the verdict leaves it as it was.
The judge call
createAgentOSConductJudge({ provider, model, apiKey?, baseUrl?, budget?, effort?, thinkingHeadroomTokens? }) sends each attempt through generateText() with thinking off (thinking: false), effort 'low' by default, no temperature, top_p or top-k, one attempt and no fallback provider, so the classifier's chain is the only one. A provider other than openai needs a model. On Claude Haiku 5.5, "Thinking is on by default and counts toward max_tokens" and thinking can be turned off at low, medium and high effort (Prompting Claude Haiku 5.5); its migration guide says to omit temperature, top_p and top_k (migration guide). The answer's 200 tokens are the answer's alone: a route whose model always thinks, or another vendor's reasoning model, gets thinkingHeadroomTokens, added to the request's output cap, sized from your own dry run.
An invoker you write yourself is (request) => Promise<{ text, stop }>, where stop is 'cap' when the model stopped at the output cap and 'end' otherwise, and it throws ConductJudgeDeclinedError(category) when its vendor declined the request. It may throw ConductJudgeEmptyAnswerError when its vendor can turn a refusal into an empty answer (see below).
A declined request
Claude Haiku 5.5 runs safety classifiers that can decline a request with stop_reason: "refusal", and sending the same request again usually returns another refusal (Safeguard refusals). Other vendors decline by a content filter: OpenAI's chat answer ends with finish_reason content_filter "if content was omitted due to a flag from our content filters" (OpenAI's OpenAPI file, CreateChatCompletionResponse). createAgentOSConductJudge() turns each of these into ConductJudgeDeclinedError: an error whose typed fields say content_filter, content_policy_violation or safety_violations (its code or type, the OpenAI provider's openaiErrorCode or openaiErrorType, or the vendor's own error object in details.error, as AgentOS's provider keeps a 400's code there), and an answer AgentOS reports with finish reason content_filter, whatever part of it arrived. The category is one of cyber, bio, frontier_llm, general_harms or other, read from the error's typed fields and never from its message; a filter's stop is other. The guard holds the message for a person: a BLOCK CONDUCT_JUDGE_DECLINED with review code JUDGE_DECLINED, with failClosed true or false. It is never retried, never sent to the second route and never cached, and one conduct.judge_declined line is written. How often your benign traffic is declined is for your own dry run to measure.
OpenAI also refuses outright: a chat answer whose message has a refusal string and null content (ChatCompletionResponseMessage in OpenAI's OpenAPI file), or a Responses answer whose content holds a part of type refusal (Refusals with Structured Outputs). Both end with an ordinary stop. AgentOS 0.17.1 keeps neither: generateText() returns empty text with finish reason stop, which on its own would read as a failed parse, be retried, and then let a second route's clean answer allow the refused text. So createAgentOSConductJudge() does two things:
- Where AgentOS keeps the refusal as a typed signal (a
refusalfield on the result, or finish reasonrefusal), the judge throwsConductJudgeDeclinedError('other'), and the message is held as above, with no retry. Arefusalfield that istrueor a string, the empty string included, is a decline at any finish reason (an answer the output cap cut included) and whatever text came beside it. A field that is absent,undefined,nullorfalseis not. - Where it does not, an empty answer at an ordinary stop on the
openairoute, or on a route with no provider named, is thrown asConductJudgeEmptyAnswerError. It is retried on its route like a failed attempt (oneconduct.attempt_failedline, causeempty). If the route's attempts end with no usable answer, the message is held as a decline is (CONDUCT_JUDGE_DECLINED, review codeJUDGE_DECLINED,declined: 'other'), withfailClosedtrue or false. It is never sent to the second route, so a refusal never ends in an allow. A model that truly answers nothing is held the same way: the safe side is a person's review.
Events
MODERATION_EVENTS names the seven lines the guards write through the logger you pass, for a host on AgentOS's allow-list logger to add to its map: moderation.signal_skipped, conduct.attempt_failed, conduct.judge_declined, conduct.unavailable, conduct.stopped, conduct.cache_failed and conduct.rating_unknown. Their fields are ids, fixed words and numbers. No line holds a message's text, a vendor's answer, an HTTP body or an error's message, and with no logger the guards write nothing.
Logging
When the ONNX tier cannot load, the guardrail writes one line, once per guardrail instance:
ml-classifiers: the ONNX tier is unavailable (Xenova/toxic-bert could not load: <error>); classifying with the keyword tier
It goes to the logger passed to createMLClassifierGuardrail, else the logger in the pack context or the activation context, else the console.
Lazy loading and optional install
This package is an optional dependency of @framers/agentos-extensions-registry. The registry ships catalog metadata; createCuratedManifest() calls import.meta.resolve() per entry and skips anything not installed. npm install @framers/agentos-ext-ml-classifiers is the gate.
Nothing loads at activation. The guardrail imports @huggingface/transformers and loads the model on its first classification.
The guardrail registers with config.canSanitize = false and config.evaluateStreamingChunks = false: it never rewrites text, runs in Phase 2 of the two-phase dispatcher (parallel classifiers), and on output it reads only the final response.
For the extension model, see How extensions stay optional and lazy and the auto-loading guide.
What the package holds
The published package holds dist (the built JavaScript, its type declarations and their source maps), src (the TypeScript the maps point to), this README and the licence. Tests, workflows, scripts and build configuration are not published.
License
Apache 2.0, see the repo root LICENSE.