Skip to main content

Moderating voice chat

This guide wires up a complete moderation loop: a plugin flags toxic messages, Cortex escalates repeat offenders from a warning to mutes and bans, the bot enforces mutes inside the room, and your token server keeps banned players out.

You’ll need a backend API key with the plugins, settings and sanctions scopes for setup, and a separate key with only sanctions.read for your token server (see Scopes).

1. Activate the moderation plugins​

The Profanity Filter checks every transcribed message and annotates it (profanity, with flagged: true when it crosses your threshold). The Auto-Sanction plugin turns those flags into sanctions along an escalation ladder. (All catalog plugins and their settings are listed under Plugins.)

import { CortexClient } from "@4players/odin-cortex";

const client = new CortexClient({
baseUrl: "https://cortex.odin.4players.io",
apiKey: process.env.CORTEX_API_KEY,
});
const project = client.project("your-project-id");

// 1a. Flag messages — runs on your own OpenAI key
const profanity = await client.pluginCatalog.get("profanity-filter");
await project.plugins.create({
pluginId: profanity.id,
settings: { apiKey: process.env.OPENAI_API_KEY, threshold: 0.5 },
});

// 1b. Escalate: warn → 5 min mute → 30 min mute → 24 h ban (the last step repeats)
const autoSanction = await client.pluginCatalog.get("auto-sanction");
await project.plugins.create({
pluginId: autoSanction.id,
settings: {
ladder: ["warn:1", "mute:5", "mute:30", "temp_ban:1440"],
flaggedCount: 3, // flags per step
windowMinutes: 60, // counted within this window
lookbackDays: 30, // earlier auto-sanctions in this period raise the level
},
});

Flags are counted per player, and only those since the player’s last auto-sanction — so every step of the ladder needs three fresh violations. Each auto-sanction ends after its step’s duration; nothing is permanent unless a step says so ("perm_ban:permanent").

Prefer your own rules?

Skip the Auto-Sanction plugin and decide in a serverless function instead — see Custom rules in a function below.

2. Turn on enforcement in the room​

With sanctionPush on, the Cortex bot tells the room about sanctions and enforces voice mutes on the receiving side: every client stops playing the muted player, and the bot stops transcribing them.

await project.settings.update({ sanctionPush: true });

The bot reads this setting when it joins a room, so it applies from the next session.

Update your clients first

Only enable sanctionPush once your game clients handle the frames (step 3). With it on, the bot stops transcribing muted players — a client that ignores the frames keeps playing the muted voice, and then nobody moderates it.

3. Handle sanction frames in the game client​

The bot sends cortex.sanction and cortex.mutes frames as ODIN room messages. Your client keeps a set of muted peers and applies it:

  • cortex.mutes (sent to every peer that joins) replaces the set.
  • cortex.sanction with mute: true adds its peerIds; mute: false (revoked or expired) removes them.
  • Every muted peer gets channel mask 0 (ODIN 2.x); everyone else is restored.
  • A ban notice (temp_ban / perm_ban, sent only to the banned player) means: leave the room.

In a web or Node client, use the decoder from @4players/odin-cortex (1.10.0 or newer):

import {
decodeCortexRoomFrame,
CortexRoomFrameType,
CORTEX_MUTED_PEER_TAG,
type CortexSanctionFrameData,
type CortexMutesFrameData,
} from "@4players/odin-cortex";

const muted = new Set<number>();

// Wire to your ODIN SDK's PeerJoined event
function onPeerJoined(peerId: number, tags: string[]) {
if (tags.includes(CORTEX_MUTED_PEER_TAG)) muted.add(peerId);
applyMutes();
}

// Wire to MessageReceived — only for messages from the Cortex bot peer
function onBotMessage(bytes: Uint8Array) {
const frame = decodeCortexRoomFrame(bytes);
if (!frame) return;

if (frame.type === CortexRoomFrameType.MUTES) {
const { mutes } = frame.data as unknown as CortexMutesFrameData;
muted.clear();
for (const m of mutes) muted.add(m.peerId);
}

if (frame.type === CortexRoomFrameType.SANCTION) {
const s = frame.data as unknown as CortexSanctionFrameData;
for (const peerId of s.peerIds) {
if (s.mute) muted.add(peerId);
else muted.delete(peerId);
}
if (s.sanction.type === "warn" && s.sanction.status === "active") showWarning(s.sanction.reason);
if (s.sanction.type.endsWith("_ban") && s.sanction.status === "active") leaveRoom(s.sanction.endAt);
}

applyMutes(); // channel mask 0 for every peer in `muted`, restore the rest
}

How to recognise the bot peer and the full frame format are covered in Room Push.

4. Keep banned players out​

ODIN checks a token only when a peer joins, so ban checks belong in the place that hands out room tokens — your token server. Give it a key with sanctions.read only: it can check sanctions, but never issue or lift one.

const reader = new CortexClient({
baseUrl: "https://cortex.odin.4players.io",
apiKey: process.env.CORTEX_SANCTIONS_READ_KEY, // scope: sanctions.read
}).project("your-project-id");

async function canJoin(externalUserId: string) {
const active = await reader.sanctions.getActive(externalUserId);
const ban = active.find((s) => s.type === "temp_ban" || s.type === "perm_ban");
return ban ? { allowed: false, until: ban.endAt } : { allowed: true };
}

getActive() answers the common case — no sanctions — from a cache, and reflects a revoke at once, so it is cheap to call on every join.

Key sanctions on externalUserId

Sanctions created by the plugins carry the player’s externalUserId — the user id your game uses in ODIN. Key your own sanctions on it too: a returning player is still muted or banned on their next join, in any session of the project. See Users & Participants.

Bans during a running session

A ban issued while the player is in the room reaches their client as a notice (step 3), and the client leaves. A server-side kick isn’t available yet — it will follow once ODIN Voice offers a server-side room API.

5. Appeals & audit​

Moderators review and lift sanctions in the dashboard, or from your backend. Lifting a sanction keeps the full history — nothing is deleted — and the bot unmutes the player in the room right away:

const sanction = await project.sanctions.get(sanctionId);
await sanction.revoke({ reason: "Appeal upheld" });

createdBy, createdAt, revokedBy and revokedAt are all retained for accountability. A revoked auto-sanction no longer counts towards the player’s escalation level.

Custom rules in a function​

If the ladder doesn’t fit, react to the annotations yourself in a serverless function. ctx.cortex is pre-authorized, so the function needs no API key:

exports.onMessageAnnotationCreated = async (event, ctx) => {
const { type, content, messageId, externalUserId } = event.payload.data;

// Only act on flagged profanity annotations
if (type !== "profanity" || !content.flagged) return;
if (!externalUserId) return;

// Already muted? Don't stack sanctions.
const active = await ctx.cortex.sanctions.getActive(externalUserId);
if (active.some((s) => s.type === "mute")) return;

await ctx.cortex.sanctions.create({
externalUserId, // key on the user so the sanction survives across sessions
type: "mute",
reason: "Automated: profanity",
endAt: new Date(Date.now() + 10 * 60_000).toISOString(), // 10 minutes
metadata: { messageId, evidence: content },
});
};

The loop​