Skip to main content

Room Push

While a session is running, the Cortex bot sits in the ODIN room as a regular peer. Cortex uses that seat as a back channel into the room: game clients receive Cortex data over the voice connection they already hold — no extra connection, no polling, no endpoint to expose. ODIN fans each message out to the players, so pushing to a full room costs the same as pushing to one player.

What travels through the room:

FrameSent whenSwitched on by
cortex.transcriptA segment of speech was transcribed (live captions)Project setting transcriptPush
cortex.sanctionA sanction of a player in the room changedProject setting sanctionPush
cortex.mutesA peer joins — the complete list of peers it must not hearProject setting sanctionPush
Your own typeYour backend or function sends a messageAlways available

All of these are sent by the bot, so room push works only while a bot is in the room — that is, while a session for the room is active.

Sending your own messages​

Push any JSON object to the players of a live session — a countdown, a match result, a notification — with project.rooms.send():

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

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

const sent = await project.rooms.send(sessionId, {
type: "match.countdown",
data: { secondsLeft: 10 },
// optional — omit to reach everyone in the room
targetExternalUserIds: ["alice"],
});

console.log(sent.id); // the frame id the clients will see

Over REST this is POST /api/projects/{projectId}/sessions/{sessionId}/room-messages — see Send a message into the ODIN room of a session. The call answers 202 with { id, sessionId, type, sizeBytes, acceptedAt } once the bot has the message.

RuleValue
type1–64 characters: letters, digits and ._:-, starting with a letter or digit. Must not start with cortex. (reserved for Cortex’s own frames)
dataAny JSON object
SizeAt most 8 KB per encoded frame, otherwise 413
Rate20 messages per second per room (shared by all senders), otherwise 429 with Retry-After
TargetstargetExternalUserIds (up to 64) reaches every peer of those users. Users who aren’t in the room are skipped — a list where nobody is present sends nothing; it never turns into a broadcast
PresenceNo bot in the room → 409. The message is not queued

API keys need the rooms.push scope (see Scopes).

From a serverless function​

Inside a serverless function, ctx.cortex is already authorized — no API key needed:

// Someone said "good game" — show an emote above their head in every client
exports.onMessageCreated = async (event, ctx) => {
const { sessionId, content, externalUserId } = event.payload.data;
if (!/\bgood game\b/i.test(content)) return;

await ctx.cortex.rooms.send(sessionId, {
type: "match.emote",
data: { emote: "gg", from: externalUserId },
});
};
Runtime v6 or newer

ctx.cortex.rooms.send() needs function runtime v6 or newer. If your project is pinned to an older runtime, upgrade it from the dashboard.

Push is the fast path — keep a source of truth

Delivery is real-time but best effort: a client that was disconnected for a moment misses frames. For state a client must not miss, keep an authoritative copy it can fetch after a reconnect (for the transcript, that is the message cursor).

Receiving frames in the client​

ODIN delivers every frame as an ordinary room message (MessageReceived) from the bot peer. Since the room’s message channel is shared with your own app traffic (chat, game state), the rule is simple: a message from the bot peer is Cortex; everything else is yours.

Recognising the bot peer​

  • With the access_key token provider, Cortex signs the tag cortex:bot into the bot’s token. Clients see it in the peer’s tags on PeerJoined, and players cannot forge it.
  • With the rooms or webservice token provider, the bot joins without tags. Identify it by its user id instead (project setting botUserId, default transcription-bot). That’s fine for a trusted game, but a peer joining with the same user id could imitate the bot.

Frame format​

BytesContent
0–1Magic 0x43 0x58 (ASCII CX)
2Protocol version, currently 0x01
3…UTF-8 JSON { "type": string, "id": string, "seq": number, "data": object }
  • type — cortex.* for frames Cortex emits itself, otherwise the type you sent.
  • id — unique frame id. For your own messages, the id the API returned to the sender.
  • seq — a counter of the bot’s connection, starting at 1 when the bot joins. A gap means the client missed frames; a restart at 1 means the bot rejoined.
  • data — the payload.

Bytes without the magic, or with an unknown version, are not Cortex frames — ignore them.

Decoding in TypeScript​

Web and Node clients can use the decoder in @4players/odin-cortex (version 1.10.0 or newer). It returns null for anything that isn’t a Cortex frame:

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

let botPeerId: number | undefined;
let lastSeq = 0;

// Wire these to your ODIN SDK's PeerJoined / MessageReceived events.
function onPeerJoined(peerId: number, tags: string[]) {
if (tags.includes(CORTEX_BOT_PEER_TAG)) botPeerId = peerId;
}

function onMessageReceived(senderPeerId: number, bytes: Uint8Array) {
if (senderPeerId !== botPeerId) return; // app traffic, not Cortex

const frame = decodeCortexRoomFrame(bytes);
if (!frame) return;

if (lastSeq !== 0 && frame.seq !== lastSeq + 1) catchUp(); // missed frames
lastSeq = frame.seq;

switch (frame.type) {
case CortexRoomFrameType.TRANSCRIPT:
showCaption(frame.data as unknown as CortexTranscriptFrameData);
break;
case CortexRoomFrameType.SANCTION:
applySanction(frame.data as unknown as CortexSanctionFrameData);
break;
case CortexRoomFrameType.MUTES:
replaceMutes(frame.data as unknown as CortexMutesFrameData);
break;
case "match.countdown":
showCountdown(frame.data.secondsLeft as number);
break;
}
}

Other engines (Unity, Unreal, native) implement the same four steps: accept only the bot peer, check magic and version, watch seq for gaps, switch on type.

Cortex frames​

cortex.transcript​

One transcribed segment of one speaker, sent right after Cortex stores it — typically a few seconds after the speaker finishes a sentence.

{
"segmentId": "0c6e…",
"peerId": 17,
"participantId": "5b1d…",
"text": "ready when you are",
"lang": "en",
"interim": false,
"seq": 42,
"ts": "2026-09-23T10:00:03.120Z"
}

Attach the line to peerId. seq is the transcript cursor (see below). interim is part of the protocol for future streaming speech-to-text providers: an interim segment is later replaced by a frame with the same segmentId. Today every segment is final.

The project setting transcriptPush decides who receives it:

ValueRecipients
off (default)Nobody
allEvery peer in the room — live captions
ownOnly the peer who spoke the segment
Why it’s off by default

Every client that receives transcript text can store and forward it. Turning it on is a deliberate decision — a recording announcement covers recording, not handing the text to every player.

cortex.sanction and cortex.mutes​

With the project setting sanctionPush on, the bot tells the room about sanctions of players who are in it, and enforces voice mutes on the receiving side. cortex.mutes is the complete list of muted peers, sent to every peer that joins. Frame shapes and the client rules are described in Moderation → Enforcement.

Configuring the settings​

Both settings are part of the project settings — set them in the dashboard or with the SDK. The bot reads them when it joins a room, so a change applies from the next session.

await project.settings.update({
transcriptPush: "all", // "off" | "all" | "own"
sanctionPush: true, // only once your clients handle cortex.sanction / cortex.mutes
});

Reconnects and gaps​

Room push removes the need to poll — it doesn’t replace the REST reads. When a client reconnects, or sees a gap in the frame seq, it catches up on the transcript through the cursor: every cortex.transcript frame carries the transcript seq to resume from.

const session = await project.sessions.get(sessionId);
const page = await session.listMessages({ after: lastTranscriptSeq, limit: 200 });
render(page.messages);
lastTranscriptSeq = page.nextCursor ?? page.latestSeq; // more pages waiting? keep paging

Limits​

  • Only while a bot is in the room. No active session, no push (409 for your own messages).
  • ODIN 1.x and 2.x both deliver frames. Receiver-side mutes need channel masks (ODIN 2.x).
  • End-to-end encrypted rooms work — frames go through the bot’s cipher like everything else it sends.