Moderation
Cortex turns voice into text, and text is something you can moderate. Moderation in Cortex has three parts:
- Plugins analyze the transcript and produce annotations (e.g. a toxicity flag on a message).
- Sanctions record the moderation actions taken in response (warn, mute, ban…), with a complete audit trail — created by a plugin, a moderator or your backend.
- Enforcement keeps sanctioned players out of the room or out of everyone’s ears: a ban check before the join, and — through the Cortex bot — mutes enforced inside the room.
Plugins & annotations
A plugin is a first-party extension that reacts to domain events and attaches structured output. You don’t write plugin code — you activate plugins for a project and configure them — see Plugins for the catalog and settings. (For your own logic, use serverless functions.)
The moderation plugins work as a pair:
| Plugin | Slug | What it does |
|---|---|---|
| Profanity Filter | profanity-filter | Checks every transcribed message and attaches a profanity annotation with flagged, per-category scores and the threshold used. Runs on your own OpenAI key (apiKey setting) |
| Auto-Sanction | auto-sanction | Counts a player’s flagged messages and issues sanctions along an escalation ladder |
For AI-driven, context-aware moderation beyond a word-list, activate Amanda by Aiba — a partner plugin that classifies every transcribed message (toxicity, hate speech, grooming and more) and sends sanctions back to Cortex for you to enforce.
Plugin output is stored as annotations:
- Message annotations — attached to a single message (e.g.
profanity). Emitmessage.annotation.created. - Session annotations — attached to a whole session (e.g. a
summary). Emitsession.annotation.created.
The annotation body is free-form and plugin-defined, carried under a content object on
the event — so you can react to exactly the fields a plugin produces.
Sanctions
A sanction is a recorded moderation action. Sanctions can be created by a moderator in the dashboard, by a plugin that detected a violation, or directly via the API from your game backend. Every sanction belongs to a project: a project-scoped API key only ever sees and changes the sanctions of its own project.
API keys need the sanctions scope to create, update and revoke sanctions. A backend that only
checks sanctions — for example your token server — should get sanctions.read, which opens the
read routes only. See Scopes.
Sanction types
Cortex supports a graduated set of actions so you can match the response to the offense:
| Type | Effect |
|---|---|
warn | Record-only warning, no participation impact |
mute | Audio muted |
listen_only | Can hear but not speak |
text_only | Voice disabled, text still allowed |
shadow_mute | Muted without notifying the user |
temp_ban / perm_ban | Blocked for a window / permanently |
human_review | Flagged for a moderator, no user-visible effect |
Game-specific variants (rate_limit, ranked_restriction, queue_delay, party_restriction)
are stored and emitted like any other sanction; enforcing them is up to your game.
A sanction runs from startAt (default: now) to endAt. Without an endAt it is permanent.
Cross-session bans
Key a sanction on externalUserId — the user id your game uses in ODIN — rather than on a single
participant. It then applies everywhere in the project: in every session, and even after the
participant record was erased.
getActive() is built for the hot path of a join: an unsanctioned player — the common case —
is answered from a cache, and a revoke takes effect immediately. To check a whole lobby at once,
pass several participants:
Escalation ladder
The Auto-Sanction plugin turns flags into sanctions and escalates with every repeat offense. It counts the flagged messages of each player separately — one player’s outbursts never put another player over the limit — and only the flags since that player’s last auto-sanction, so every step needs fresh violations.
| Setting | Default | Meaning |
|---|---|---|
ladder | — | The steps, e.g. ["warn:1", "mute:5", "mute:30", "temp_ban:1440"] (type:minutes, or type:permanent). The last step repeats |
flaggedCount | 3 | Flags that trigger the next step |
windowMinutes | 60 | Window in which flags are counted |
lookbackDays | 30 | How far back earlier auto-sanctions count towards the player’s level |
annotationType | profanity | Which annotations count as flags |
sanctionType / sanctionDurationMinutes | mute / 60 | The single step used when no ladder is set |
A player’s level is the number of their earlier auto-sanctions within lookbackDays. Sanctions a
moderator revoked don’t count — a human judged them wrong.
Every auto-sanction gets an end time — a missing, empty or zero duration means 60 minutes.
A permanent sanction needs an explicit step such as "perm_ban:permanent". For a warn, the
duration is how long the client shows the warning.
Audit trail
Revoked sanctions are never deleted — the full history (createdBy, createdAt,
revokedBy, revokedAt, reason) is retained for accountability and dispute resolution.
Auto-sanctions also record their escalation level and the step of the ladder in metadata.
Enforcement
Cortex is the system of record for moderation decisions: it stores them, emits
sanction.created, sanction.updated and sanction.revoked, and — through the bot in the room —
enforces voice mutes. Enforcement works in layers:
Before the join
ODIN checks a room token only when a peer joins — so the moment to keep a banned player out is before they get one. Wherever your game hands out room tokens, ask Cortex first:
In the room
With the project setting sanctionPush on, the Cortex bot carries sanctions into the room as
Room Push frames, and enforces voice mutes on the receiving side:
- When a player gets a voice mute (
mute,listen_only,text_only,shadow_mute), the bot broadcastscortex.sanctionwithmute: trueand the player’speerIds. Every client stops playing those peers — with ODIN 2.x SDKs by setting the peer’s channel mask to 0, so the server stops delivering that audio altogether. A modified client of the muted player can’t get around this; only a modified receiver could keep listening, and that only harms itself. - On revoke, and on its own when the mute reaches its
endAt, the bot sendsmute: false(action: "revoked"or"expired"). Clients should rely on this frame rather than their own clock. - Every peer that joins receives
cortex.mutes— the complete list of peers it must not hear. - The bot stops listening to a muted peer too: no transcript, no further flags, no speech-to-text cost.
shadow_muteis enforced the same way, but never sent to the muted player.- Other sanctions (
warn, bans, …) are sent only to the sanctioned player’s own peers, as a notice — show the warning, or leave the room on a ban.
What your game client does — keep a set of muted peer ids:
cortex.mutesreplaces the set.cortex.sanctionwithmute: trueadds itspeerIds; withmute: falseit removes them.- Mute every peer in the set (channel mask
0), and restore the others.
In TypeScript, decode frames with decodeCortexRoomFrame() from
@4players/odin-cortex 1.10.0 or newer:
See Room Push for how to recognise the bot peer and the frame format.
sanctionPush once your clients handle the framesWith it on, the bot stops transcribing muted players. A client that ignores the frames keeps playing the muted voice — and then nobody transcribes or moderates it.
Not yet available
ODIN Voice has no server-side “remove this peer” or “this peer may not publish” today. So a ban issued during a running session reaches the player as a notice their client acts on (leave the room), and a mute is enforced by the receiving clients. Hard enforcement — kicking a peer and revoking its permission to publish — will follow once ODIN Voice provides a server-side room API.
A typical moderation loop
Related
- Moderation guide — wire up the loop step by step
- Room Push — frames the bot sends into the room
- Plugins — the plugin catalog, settings and annotations
- Amanda by Aiba — partner plugin for AI-driven, context-aware moderation
- Serverless functions — build custom moderation logic
- Domain Events reference