Skip to main content

Moderating voice chat

This guide wires up a moderation loop: a plugin flags toxic messages, your code reacts to the flag, and you record a sanction your game server enforces on the next join.

1. Activate a moderation plugin

Browse the catalog and activate a toxicity/hate-speech plugin for your project. It will run on every transcribed message and attach annotations to the ones it flags.

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

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

const catalog = await client.pluginCatalog.list();
// find the moderation plugin you want, then:
await project.plugins.install({
pluginId: "toxicity",
settings: { /* per-plugin config */ },
});

2. React to flagged messages

You can react either in real time over SSE or, for durable handling, in a serverless function. Here’s the function approach — it runs on every annotation:

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

// Only act on toxicity annotations over your threshold
if (type !== "toxicity") return;
if ((content.score ?? 0) < 0.8) return;

// Record a sanction — your game server will enforce it
await ctx.cortex.sanctions.create({
externalUserId, // key on the user so the ban survives across sessions
type: "mute",
reason: `Automated: toxicity ${content.score}`,
metadata: { messageId: event.resourceId, evidence: content },
});
};
Key cross-session sanctions on externalUserId

Using externalUserId (rather than a single participant id) means a returning player is still muted/banned on their next join, even in a different session. See Users & Participants.

3. Enforce on the next join

Cortex records sanctions; your systems enforce them. On every room join, ask Cortex whether the user has active sanctions and act accordingly:

const active = await project.sanctions.getActive(externalUserId);
if (active.some((s) => s.type === "mute" || s.type === "perm_ban")) {
// refuse the join, mute the peer, etc.
}

You can also drive enforcement from the sanction.created event via a webhook.

4. Appeals & audit

Lifting a sanction keeps the full history — nothing is deleted:

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

createdBy, createdAt, revokedBy and revokedAt are all retained for accountability.

The loop