Skip to main content
You are watching a Twitch stream, and the chat moves faster than you can read it. This cookbook sorts each message as it arrives: questions are highlighted, spam, insults and unrequested gameplay tips are hidden, and spoilers are blurred. A keyword list could catch 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. Send each message to Jev in its own request, with the stream title and game. Ask three questions about it: what kind of message it is, whether it is toxic (insulting, hateful, or harassing), and whether it spoils the game. Plain code then turns those answers into what the viewer sees. The video demo below shows a Jev-powered Chrome extension filtering a live Twitch chat:
This cookbook covers how messages are categorized and filtered by Jev using 29 example chat messages. Seven were hidden, two blurred, and six highlighted as questions. Five chat messages go to Jev at once, each in its own request with the stream's game. The category, toxic, and spoiler answers come back, and decide() shows, highlights, hides, or blurs each message in the viewer's chat Five chat messages go to Jev at once, each in its own request with the stream's game. The category, toxic, and spoiler answers come back, and decide() shows, highlights, hides, or blurs each message in the viewer's chat By the end of this cookbook you will have 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

Create a new file named 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:
Every API response is saved in 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 like PogChamp). 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 category question is a Choice over five kinds of message. The label it picks decides what happens to the message: show, highlight, dim, or hide.
  • The toxic question is a Noul. If its probability is 0.7 or higher (the threshold TOXIC_AT, set further down), the message is hidden, whatever its category.
  • The spoiler question is a Noul. If its probability is 0.7 or higher (the threshold SPOILER_AT), the message is blurred, unless it is already hidden.
The category describes the message’s main purpose. Repeated emotes and cheering count as 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

The classify() 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.
The backseat advice comes back as 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

The decide() 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:
  1. A message at or above TOXIC_AT is hidden.
  2. A message whose category has the rule hide is hidden.
  3. A message at or above SPOILER_AT is blurred.
Any message left gets the rule for its category. On the sample messages, every toxic probability is 0.97 or higher or 0.24 or lower, and every spoiler probability 0.94 or higher or 0.20 or lower, so any threshold between those gives the same result here. Chat from your own channel will land in between; check both thresholds against it before using them live.

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, and pool.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.
That is the whole filter: 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, and decide() 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.

Open it in the playground

The link below opens the playground with the state for the backseat message and the three questions filled in. Run the call live there, or edit the questions and run it again.
Open the state + questions in the TypeSafe playground →