

classify(), which asks the three questions
about one message, and decide(), which turns the answers into show, highlight, dim,
hide, or blur. When you change a rule, for example if spam then hide, only decide()
runs again, with no new request to Jev. To filter live chat on Twitch or another
platform, connect this logic to a custom chat viewer, moderation tool, or browser
extension, adapting the message input and questions to your stream.
Setup
twitch_chat_filter.py. As you work through this cookbook,
copy each Python code block into that file, in order. For a live run, set
TYPESAFE_API_KEY in your environment. Once you have added all the blocks, run this
command from the directory containing it:
json_cache.json
beside the script. Download it there to replay the published numbers instead of calling
the API. In a notebook, put the cache in the notebook’s working directory instead. To run
everything live again, move the cache aside and set your key. Do the same when changing
TYPESAFE_MODEL, because the cache key below includes the state and questions, not the
model.
Numbers below came from jev-1.13.0 on 2026-09-25.
Load a stretch of chat
We wrote these 29 messages ourselves for an Elden Ring stream, so every kind of message turns up at least twice. Two of the messages are emote-only lines (emotes are small chat images on Twitch, typed as words likePogChamp).
Whether a message is a spoiler or backseat advice (gameplay tips the streamer did not ask
for) depends on which game is being played, so the stream title and game go into every
request.
Write the questions
We’ll ask three questions, which are all answered in one request, against the same state: the stream plus one message.- The
categoryquestion is aChoiceover five kinds of message. The label it picks decides what happens to the message: show, highlight, dim, or hide. - The
toxicquestion is aNoul. If its probability is 0.7 or higher (the thresholdTOXIC_AT, set further down), the message is hidden, whatever its category. - The
spoilerquestion is aNoul. If its probability is 0.7 or higher (the thresholdSPOILER_AT), the message is blurred, unless it is already hidden.
hype; unsolicited promotion counts as spam. A link alone is not spam.
conversation is the fallback when none of the other categories fits.
Classify one message
Theclassify() function below builds the state from one message and sends the request.
The cache saves each answer under a key made from the state and the questions, written
out as JSON. Editing a question changes that key, so the next run sends a new request
instead of replaying the old answer.
backseat at 1.00, with low toxic and spoiler. The
Night of Black Knives message narrowly favors conversation over backseat, and the
spoiler question catches it. The last message reads like an insult, but toxic stays
low. With “love you tho” on the end, it fits the “light banter” description in the
question.
Decide what the viewer sees
Thedecide() function below only reads the answers, it doesn’t send requests. It checks
three things and decides what to do based on a set of rules:
- A message at or above
TOXIC_ATis hidden. - A message whose category has the rule
hideis hidden. - A message at or above
SPOILER_ATis blurred.
Filter the whole chat
The three messages above get cached, leaving 26 new requests on a first live run. No message depends on another, so the requests run eight at a time, andpool.map returns the answers in chat order.
The chat prints below as the viewer sees it. A hidden message shows only the reason it was
hidden, a blurred one shows only its author, and highlighted questions are marked with
>>. The label on the right of each line is the category answer.
QUESTIONS, classify(), and decide(). To run it on your own
chat, replace STREAM and CHAT, or pass a different stream to classify() for each
channel.
If you hit a rate
limit, lower max_workers and run again; completed answers are already cached.
Here, pool.map() preserves chat order, so each result matches its message by position
in the list. In a live integration, you can instead keep the platform’s message ID or a
reference to the message’s page element alongside each request to update the right
message when its answers arrive.
Change a rule without a new request
The answers describe the message, anddecide() turns them into an action. The example
below hides hype (cheering and emote-only lines) and dims conversation, reusing the 29
results from the step above.
Before you use it on a live channel
- Each message waits for its answer. Requests in this cookbook took a median of about 0.4 seconds, so a new line appears in chat before its answer comes back. Either hide each new line until its answer arrives, or accept that a toxic message is on screen for that long.
- One request at a time can’t keep up with a busy chat. Run several requests at once, such as the eight in Filter the whole chat.
- At the time this cookbook was published, Jev was trained mainly on English. It accepted other languages but answered them less accurately, so expect more misses on a non-English channel.
- A winning category can still be uncertain. This example always applies the winning label’s rule. Before using it live, choose how to handle close category probabilities and Noul values near 0.5. You could flag those messages for review or leave them visible rather than hide them automatically. Test that policy on your own channel’s chat.
- The spoiler check has no record of the streamer’s progress. It uses the game title and the model’s knowledge to identify likely plot revelations, not whether this viewer or streamer already knows them. To filter only unseen plot points, supply the current progress and relevant plot information, then adapt the question to use that context.

