Skip to main content

Moderation

Cortex turns voice into text, and text is something you can moderate. Moderation in Cortex has three parts:

  1. Plugins analyze the transcript and produce annotations (e.g. a toxicity flag on a message).
  2. 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.
  3. 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:

PluginSlugWhat it does
Profanity Filterprofanity-filterChecks 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-Sanctionauto-sanctionCounts a player’s flagged messages and issues sanctions along an escalation ladder
Partner moderation: Amanda by Aiba

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.

// Browse the catalog
const plugins = await client.pluginCatalog.list();

// Activate one for your project with settings
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 },
});

Plugin output is stored as annotations:

  • Message annotations — attached to a single message (e.g. profanity). Emit message.annotation.created.
  • Session annotations — attached to a whole session (e.g. a summary). Emit session.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.

const sanction = await project.sanctions.create({
externalUserId: "user-123",
type: "mute",
reason: "Repeated harassment",
endAt: new Date(Date.now() + 30 * 60_000).toISOString(), // 30 minutes
});

// later
await sanction.revoke({ reason: "Appeal upheld" });

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:

TypeEffect
warnRecord-only warning, no participation impact
muteAudio muted
listen_onlyCan hear but not speak
text_onlyVoice disabled, text still allowed
shadow_muteMuted without notifying the user
temp_ban / perm_banBlocked for a window / permanently
human_reviewFlagged 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.

const active = await project.sanctions.getActive("user-123");

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:

const { sanctions } = await project.sanctions.list({
participantIds: ["p1", "p2", "p3"],
activeOnly: true,
});

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.

SettingDefaultMeaning
ladder—The steps, e.g. ["warn:1", "mute:5", "mute:30", "temp_ban:1440"] (type:minutes, or type:permanent). The last step repeats
flaggedCount3Flags that trigger the next step
windowMinutes60Window in which flags are counted
lookbackDays30How far back earlier auto-sanctions count towards the player’s level
annotationTypeprofanityWhich annotations count as flags
sanctionType / sanctionDurationMinutesmute / 60The 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.

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,
windowMinutes: 60,
},
});
Permanent only on purpose

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:

const active = await project.sanctions.getActive(externalUserId);
const ban = active.find((s) => s.type === "temp_ban" || s.type === "perm_ban");
if (ban) {
throw new Error(ban.endAt ? `Banned until ${ban.endAt}` : "Permanently banned");
}

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 broadcasts cortex.sanction with mute: true and the player’s peerIds. 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 sends mute: 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_mute is 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.
{
"action": "created",
"sanction": {
"id": "…", "type": "mute", "participantId": "…", "externalUserId": "alice",
"reason": "Auto-sanctioned (level 2): …", "startAt": "…", "endAt": "…", "status": "active"
},
"peerIds": [17],
"mute": true
}

What your game client does — keep a set of muted peer ids:

  1. cortex.mutes replaces the set.
  2. cortex.sanction with mute: true adds its peerIds; with mute: false it removes them.
  3. 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:

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

const muted = new Set<number>();

function onPeerJoined(peerId: number, tags: string[]) {
if (tags.includes(CORTEX_MUTED_PEER_TAG)) muted.add(peerId);
applyMutes();
}

function onBotMessage(bytes: Uint8Array) {
const frame = decodeCortexRoomFrame(bytes);
if (frame?.type === CortexRoomFrameType.MUTES) {
const { mutes } = frame.data as unknown as CortexMutesFrameData;
muted.clear();
for (const m of mutes) muted.add(m.peerId);
} else 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);
}
// Ban notices only reach the banned player's own peers
if (s.sanction.type.endsWith("_ban") && s.sanction.status === "active") leaveRoom(s.sanction);
}
applyMutes(); // set the channel mask of every muted peer to 0, restore the rest
}

See Room Push for how to recognise the bot peer and the frame format.

Only enable sanctionPush once your clients handle the frames

With 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​