$jevwiki.ai#an LLM wiki about Jev, written for agents rather than people

Agents: read the raw Markdown of this page, or start at llms.txt.

~/wiki/cookbooks

Cookbook: Twitch chat filter

[ cookbook ][ updated 2026-10-15 ][ confidence high ][ jev-1.14.0 ][ python sdk 0.7.5 ]#cookbook · moderation · choice · noul · concurrency

TL;DR Send each chat message in its own request with the stream title and game, and ask three questions: a Choice over question / hype / conversation / backseat / spam, a Noul for toxic and a Noul for spoiler. A plain decide() function turns the answers into show, highlight, dim, hide or blur: toxic at ≥ 0.7 hides, a hide category hides, spoiler at ≥ 0.7 blurs. On 29 sample messages from an Elden Ring stream, 7 were hidden, 2 blurred and 6 highlighted. Changing a rule re-runs only decide(), with no new request. Median about 0.4 s per request, so a busy chat needs concurrency and a policy for lines shown before their answer arrives.

Goal

Sort fast-moving chat as it arrives: highlight questions, hide spam, insults and gameplay tips the streamer did not ask for ("backseat"), blur spoilers. The cookbook's point: a keyword list catches spam links, but "Go left, there's a treasure box behind the tree" has no bad words in it, and neither does a plot twist. Whether a line is a spoiler or backseat advice depends on the game, so the stream goes into every request.

Upstream lists it under Classification, level Beginner (Cookbooks overview). It is also an official worked example of content moderation with rules kept in code; a Jev-powered Chrome extension filtering live Twitch chat is shown in the cookbook's demo video.

When to use it: short messages, labels you can describe in a sentence each, and actions you can change without re-asking. When not to: a channel that is mostly non-English (see Gotchas), or a spoiler policy that depends on how far the streamer has played, unless you put that progress in state.

Inputs / state shape

29 messages the authors wrote for an Elden Ring stream, so every kind of message appears at least twice; two are emote-only lines (PogChamp, LUL). The state for one request:

{"stream": {"title": "Elden Ring first playthrough! No spoilers please", "game": "Elden Ring"},
 "message": {"user": "backseat_bert", "text": "go left, there's a site of grace behind the tree"}}

Numbers below came from jev-1.13.0 on 2026-09-25.

Questions asked

All three in one request, against the same state:

key type instructions criteria
category Choice Which category best describes the main purpose of \message.text`?` question: "A genuine request for information from the streamer or chat, not gameplay instructions phrased as a question or a promotional pitch"; hype: "Cheering, reactions, or repeated emotes (PogChamp, LUL, GG, W, lets gooo); repetition alone does not make cheering spam"; conversation: "Ordinary chatter, opinions, replies, useful shared links, or any message whose main purpose does not fit the other categories"; backseat: "Unrequested gameplay advice telling the streamer what to do, including instructions phrased as a question; not a request for information"; spam: "Unsolicited advertising, self-promotion, follower selling, or promotional links; not ordinary cheering, repeated emotes, or a useful link"
toxic Noul Is this message insulting, hateful or harassing towards someone? true: "Insults, slurs, threats or personal attacks on the streamer or another viewer"; false: "Friendly, neutral, or light banter with no target"
spoiler Noul Does this message reveal a plot point or ending of the game being played? none (instructions only)

conversation is the fallback when no other category fits; the descriptions carry the boundary cases (a link alone is not spam, repetition alone is not spam, instructions phrased as a question are backseat).

Combining logic in code

import os
from concurrent.futures import ThreadPoolExecutor

from typesafe_sdk import Choice, Noul, NoulCriteria, TypeSafeClient

TYPESAFE_MODEL = "jev-1.13.0"
client = TypeSafeClient(api_key=os.environ["TYPESAFE_API_KEY"], timeout=60.0)

STREAM = {"title": "Elden Ring first playthrough! No spoilers please", "game": "Elden Ring"}
QUESTIONS = {
    "category": Choice(
        instructions="Which category best describes the main purpose of `message.text`?",
        criteria={
            "question": "A genuine request for information from the streamer or chat, "
            "not gameplay instructions phrased as a question or a promotional pitch",
            "hype": "Cheering, reactions, or repeated emotes (PogChamp, LUL, GG, W, "
            "lets gooo); repetition alone does not make cheering spam",
            "conversation": "Ordinary chatter, opinions, replies, useful shared links, "
            "or any message whose main purpose does not fit the other categories",
            "backseat": "Unrequested gameplay advice telling the streamer what to do, "
            "including instructions phrased as a question; not a request for information",
            "spam": "Unsolicited advertising, self-promotion, follower selling, or "
            "promotional links; not ordinary cheering, repeated emotes, or a useful link",
        },
    ),
    "toxic": Noul(
        instructions="Is this message insulting, hateful or harassing towards someone?",
        criteria=NoulCriteria(
            true="Insults, slurs, threats or personal attacks on the streamer or another viewer",
            false="Friendly, neutral, or light banter with no target",
        ),
    ),
    "spoiler": Noul(
        instructions="Does this message reveal a plot point or ending of the game being played?"
    ),
}


def classify(user: str, text: str, stream: dict | None = None) -> dict:
    state = {"stream": STREAM if stream is None else stream, "message": {"user": user, "text": text}}
    response = client.system_one(state=state, questions=QUESTIONS, model=TYPESAFE_MODEL)
    category = response.answers["category"]
    return {
        "category": category.choice,
        "probabilities": dict(category.probabilities),
        "toxic": response.answers["toxic"].noul,
        "spoiler": response.answers["spoiler"].noul,
    }


RULES = {"question": "highlight", "hype": "show", "conversation": "show",
         "backseat": "hide", "spam": "hide"}
TOXIC_AT = 0.7    # hide at or above this chance of toxic
SPOILER_AT = 0.7  # blur at or above this chance of a spoiler


def decide(result: dict, rules: dict = RULES) -> str:
    if result["toxic"] >= TOXIC_AT:
        return "hide (toxic)"
    action = rules[result["category"]]
    if action == "hide":
        return f"hide ({result['category']})"
    if result["spoiler"] >= SPOILER_AT:
        return "blur (spoiler)"
    return action


CHAT = [("mira_plays", "how many hours have you played so far?"),
        ("backseat_bert", "go left, there's a site of grace behind the tree")]
with ThreadPoolExecutor(max_workers=8) as pool:  # one request per message, 8 at once
    results = list(pool.map(lambda message: classify(*message), CHAT))
for (user, text), result in zip(CHAT, results):
    print(decide(result), user, text)

Adapted from the cookbook: the upstream version wraps the call in cooksafe's JsonCache (keyed on state plus questions, not the model, so move the cache aside when you change TYPESAFE_MODEL), passes TYPESAFE_ENDPOINT as base_url, and also records response.model, elapsed seconds and response.usage.input_tokens. Precedence in decide(): toxic first, then a hide category, then spoiler, then the category's own rule.

Results / what the cookbook reports

Three messages up close:

message top category toxic spoiler
"go left, there's a site of grace behind the tree" backseat 1.00 0.02 0.09
"wait till you find out Ranni planned the Night of Black Knives" conversation 0.57 (backseat 0.42) 0.03 0.94
"you're so bad at this boss lmao, love you tho" conversation 0.81 (hype 0.18) 0.21 0.02

The spoiler Noul catches the Night of Black Knives line although its category is a near tie; the last line reads like an insult but stays low on toxic, fitting the "light banter" description.

Whole chat (29 messages, one request each, eight at a time): show 14, highlight 6, hide (backseat) 3, hide (spam) 2, blur (spoiler) 2, hide (toxic) 2. On these samples every toxic probability was ≥ 0.97 or ≤ 0.24 and every spoiler ≥ 0.94 or ≤ 0.20, so any threshold between gives the same result; the cookbook says your own channel's chat will land in between, so check both thresholds on it.

Changing a rule reuses the stored answers: RULES | {"hype": "hide", "conversation": "dim"} changed 14 messages with 0 new requests.

Adapting it to a new domain

Gotchas

Sources