@4players/odin-cortex
v1.13.0
Published
Official TypeScript SDK for the ODIN Cortex API
Readme
@4players/odin-cortex
Official TypeScript SDK for the ODIN Cortex API — real-time voice transcription, AI-powered analysis, content moderation, gatherings, and serverless functions for ODIN Voice applications.
Installation
npm install @4players/[email protected]Quick Start
import { CortexClient } from "@4players/odin-cortex";
const client = new CortexClient({
baseUrl: "https://cortex.odin.4players.io",
apiKey: "ots_live_...",
});
const project = client.project("your-project-id");
// Create and start a session
const session = await project.sessions.create({
title: "My Session",
idleTimeout: 60,
externalRoomId: "room-1",
});
await session.start();
// Later: stop and retrieve messages
await session.stop();
const messages = await session.getMessages();Authentication
The SDK supports four authentication methods. The first three are backend credentials — never ship them in a game client. The fourth, the participant token, is the one that is meant for a client. You can manage your credentials in the ODIN Cortex Dashboard.
API Key
Best for server-side integrations, CI/CD pipelines, and third-party applications. Create and manage API keys in the dashboard under your account settings.
const client = new CortexClient({
baseUrl: "https://cortex.odin.4players.io",
apiKey: "ots_live_...",
});Project Secret (HMAC-SHA256)
Best for ODIN platform services and server-to-server communication. You can find your project secret in the dashboard under your project settings.
Every request is signed with HMAC-SHA256 automatically. The SDK sends X-Project-Id, X-Signature, and X-Timestamp headers on each request.
const client = new CortexClient({
baseUrl: "https://cortex.odin.4players.io",
projectId: "your-project-id",
projectSecret: "your-project-secret",
});JWT Bearer Token
Best for dashboard users and browser-based sessions. Obtain a token via the login endpoints.
const client = new CortexClient({
baseUrl: "https://cortex.odin.4players.io",
accessToken: "eyJ...",
});Participant Token (game clients)
For end-user devices. Your backend mints a short-lived, Ed25519-signed token for one player;
the device carries it and can reach only that player's own data, through client.me.
// --- On your backend (API key or project secret) ---
const { token, expiresAt, odinToken } = await backend
.project("your-project-id")
.participants.issueToken({
externalUserId: "steam:76561198000000000",
displayName: "Alice",
roomId: "boss-room-17", // optional: also returns the ODIN voice token
gatheringId: lobby.id, // optional: narrows the token to one lobby
});
// --- In the game client ---
const cortex = new CortexClient({
baseUrl: "https://cortex.odin.4players.io",
participantToken: token,
});
const me = await cortex.me.identity();
const { muted, banned } = await cortex.me.sanctions();
let after = 0;
const page = await cortex.me.listMessages(sessionId, { after }, { ifNoneMatch: `"${after}"` });
if (page) after = page.latestSeq; // `null` means 304 Not Modified — nothing newTokens live at most 15 minutes, so fetch a new one from your backend before expiresAt. Issuing
requires the participants.token scope. To invalidate every token of a project at once (a leaked
backend credential, not a single ban), call project.participants.revokeAllTokens().
Other services can verify a participant token themselves against the public keys at
GET /api/.well-known/jwks.json — no call to Cortex needed.
Usage
The SDK is organized around resource managers accessed through a project scope. Methods like create() and get() return live objects: every field from the API response is available directly on the object (e.g. session.title, session.status), and the object also carries action methods (start(), stop(), …) and knows its own identity.
const session = await project.sessions.get(sessionId);
session.title; // ✅ fields are on the object directly
session.status;
await session.start(); // ✅ ...and so are the action methods
// session.data.title still works but is @deprecated — prefer session.title.Live objects are also plain data: JSON.stringify(session) returns exactly the API
payload (no client internals), and a Session is assignable to SessionResponse.
Project Scope
All project-scoped resources are accessed through client.project(projectId):
const project = client.project("your-project-id");Sessions
// List all sessions
const sessions = await project.sessions.list();
// Create a session (returns a live Session object)
const session = await project.sessions.create({
title: "Team Meeting",
idleTimeout: 120,
externalRoomId: "room-42",
});
// Live object methods
await session.start();
const messages = await session.getMessages();
// Polling with a cursor + ETag — the cheap way to follow a live transcript
// from a backend or a serverless function (a 304 costs Cortex one Redis read)
let after = 0;
const page = await session.listMessages(
{ after, limit: 200, include: ["annotations"] },
{ ifNoneMatch: `"${after}"` }
);
if (page) {
console.log(page.messages, page.latestSeq); // store latestSeq as the next `after`
}
// Sessions one page at a time (newest first), optionally by status
const active = await project.sessions.listPaged({ status: "active", limit: 20 });
await session.regenerateSummary();
await session.stop();Gatherings
const gathering = await project.gatherings.create({
name: "Daily Standup",
});
// Members (sub-resource on the live object)
await gathering.addMember({ participantId: "p1" });
await gathering.sendInvitations({ participantIds: ["p2", "p3"] });
const members = await gathering.listMembers();
// Lifecycle
await gathering.start({ roomId: "room-1" });
await gathering.end();
// Join by code
const result = await project.gatherings.joinByCode({
joinCode: "ABCD1234",
participantId: "p1",
});Serverless Functions
// Create and deploy a function
const fn = await project.functions.create({
name: "on-message",
code: "export default async (ctx) => { ... }",
});
await fn.publish({ changelog: "Initial version" });
await fn.deploy();
// Runtime management
await project.functions.startRuntime();
const status = await project.functions.getRuntimeStatus();
await project.functions.stopRuntime();
// Settings and dependencies
await project.functions.updateDependencies({
dependencies: [{ name: "lodash", version: "^4.17" }],
});
await project.functions.updateEnvVars({
envVars: [{ key: "API_URL", value: "https://..." }],
});Participants and Sanctions
// Participants
const participant = await project.participants.create({
externalUserId: "user-123",
displayName: "Alice",
});
await participant.update({ displayName: "Alice Smith" });
// Sanctions
const sanction = await project.sanctions.create({
participantId: participant.id,
type: "mute",
reason: "Disruptive behavior",
});
await sanction.revoke({ reason: "Warning acknowledged" });
// Lookup
const active = await project.sanctions.getActive("user-123");
// Join gate: issuing a participant token refuses banned players
import { CortexJoinRefusedError } from "@4players/odin-cortex";
try {
const { token, odinToken, odinTokenRestrictions } = await project.participants.issueToken({
externalUserId: "user-123",
roomId: "room-1",
});
// odinTokenRestrictions.tags contains "cortex:muted" for a muted player
} catch (e) {
if (e instanceof CortexJoinRefusedError) console.log("banned until", e.sanction.endAt ?? "forever");
else throw e;
}Webhooks
Cortex delivers domain events to a URL you host. Every outbound request is
signed with HMAC-SHA256 using a secret you provide here — your receiver
verifies the X-Odin-Signature header using the same value. This webhook
secret is separate from the 4Players project secret used to authenticate
the SDK client itself; it only protects traffic that flows from Cortex to
your URL.
import { randomBytes } from "node:crypto";
// Pick any random string; store it alongside your webhook receiver so it can
// verify incoming signatures. This is NOT the 4Players project secret.
const webhookSecret = randomBytes(32).toString("hex");
const sub = await project.webhooks.createSubscription({
url: "https://example.com/webhook",
events: ["session.created", "session.updated", "message.created"],
secret: webhookSecret,
});
// listEvents() returns the delivery queue (pending / delivered / failed
// attempts) — not a catalog of subscribable event names.
const events = await project.webhooks.listEvents();Optional fields on createSubscription — authType, apiKey,
apiKeyHeader, basicAuthUsername, basicAuthPassword, customHeaders —
configure how Cortex authenticates to your webhook URL when it posts
events. Leave authType unset (defaults to "none") if your receiver
validates only via X-Odin-Signature.
See Domain Events for the full event
catalog — common names include session.created, session.updated,
message.created, participant.joined, gathering.created.
ODIN Voice Tokens
// Generate a token for client-side ODIN Voice
const token = await project.generateToken({
roomId: "room-1",
userId: "user-1",
});Room Push
The Cortex bot sits in the ODIN room of every live session, so a backend can
push messages to the players over the voice connection they already have — no
extra token, endpoint or connection on the client. Requires the rooms.push
scope; limits are 8 KB per frame and 20 messages per second per room.
// From a backend, or from a serverless function as ctx.cortex.rooms.send(...)
await project.rooms.send(sessionId, {
type: "match.countdown", // anything except the reserved "cortex." prefix
data: { secondsLeft: 10 },
targetExternalUserIds: ["user-1"], // optional; omit to reach everyone in the room
});Clients receive a Cortex Room Protocol frame through ODIN's
MessageReceived, sent from the bot peer: the bytes 0x43 0x58 0x01 ("CX",
version 1) followed by JSON { type, id, seq, data }. Besides your own
messages, the bot sends cortex.transcript (live transcript, when the
project's transcriptPush setting is on) and cortex.sanction /
cortex.mutes (when sanctionPush is on). A web or Node client can decode
them with the bundled reference decoder:
import { decodeCortexRoomFrame, CortexRoomFrameType } from "@4players/odin-cortex";
room.onMessageReceived(({ senderPeerId, message }) => {
if (senderPeerId !== botPeerId) return; // everything else is your app's own traffic
const frame = decodeCortexRoomFrame(message);
if (!frame) return; // not a Cortex frame
if (frame.type === CortexRoomFrameType.TRANSCRIPT) showCaption(frame.data);
});Push is the fast path, not the source of truth: after a reconnect, catch up
with session.listMessages({ after: lastSeq }).
Live Updates (watch)
Every list() / get() returns a one-shot snapshot. To receive live
updates instead, call watch() — a Firestore-style subscription that loads the
initial state via REST and then keeps it in sync over Server-Sent Events (SSE). No
boilerplate: no manual EventSource, no event filtering, no merge logic.
watch() returns a subscription handle:
const project = client.project("my-project-id");
// Collection: watch all sessions
const sub = project.sessions.watch();
sub.onSnapshot((sessions, changes) => {
// `sessions` is the full current array (kept in sync)
// `changes` is the diff that triggered this snapshot
console.log(`${sessions.length} sessions`);
for (const c of changes) console.log(c.type, c.id); // "added" | "modified" | "removed"
});
sub.onError((err) => console.error(err));
console.log(sub.current); // latest array, read synchronously at any time
sub.unsubscribe(); // stop listening (and release the shared connection)Watch a single object by passing an id, or call .watch() on a live object.
The value is undefined until the initial fetch resolves, and again if the object
is deleted:
const sub = project.sessions.watch(sessionId);
sub.onSnapshot((session) => console.log(session?.status));
// equivalently, from a live object:
const session = await project.sessions.get(sessionId);
const sub2 = session.watch();Supported resources. Collections and single objects are watched from the
manager (project.<resource>); nested collections are watched from a live
object you fetch first (just like listMembers() / getMessages()):
// Collection, or a single object by id — from the manager
project.sessions.watch(); // project.sessions.watch(id)
project.participants.watch(); // project.participants.watch(id)
project.sanctions.watch(); // project.sanctions.watch(id) (revoked stays, status updated)
project.gatherings.watch(); // project.gatherings.watch(id)
// A single object can also be watched from its own live object
const session = await project.sessions.get(sessionId);
session.watch();
// Nested collections live on a live object (not on project.gatherings directly)
session.watchMessages(); // session transcript — new messages stream in
const gathering = await project.gatherings.get(gatheringId);
gathering.watchMembers(); // members, keyed by participantId
gathering.watchInvitations(); // invitation status updates liveResilience. All watchers for a project share a single SSE connection
(ref-counted; closed when the last watcher unsubscribes). The connection
auto-reconnects with exponential backoff, and on every (re)connect each
subscription re-fetches its snapshot to resync — so a dropped connection never
leaves you with stale data. A client-side idle timeout (default 45s, configurable
via realtime.idleTimeoutMs) guards against dead sockets:
const client = new CortexClient({
baseUrl,
apiKey: "ots_live_...",
realtime: { idleTimeoutMs: 45_000 }, // 0 to disable
});Live updates work with all three auth mechanisms (API key, JWT, project secret).
Streaming requires a WHATWG fetch — available as the global fetch on Node ≥ 18
and in all modern browsers.
Non-Project-Scoped Resources
Some resources are accessed directly on the client:
// Authentication
const { accessToken } = await client.auth.loginWithCredentials({
email: "[email protected]",
password: "...",
});
const profile = await client.auth.getProfile();
// API keys
const keys = await client.apiKeys.list();
// The scope catalog: every grantable scope (family, label, sensitivity) plus presets
const { scopes, presets } = await client.apiKeys.getScopes();
const newKey = await client.apiKeys.create({
name: "Game backend",
// e.g. the "Game backend" preset: mint player tokens and check sanctions
scopes: ["participants.token", "sanctions.read"],
});
await client.apiKeys.revoke(newKey.id);
// Plugin catalog
const plugins = await client.pluginCatalog.list();
const plugin = await client.pluginCatalog.get("profanity-filter");
// Health check
await client.healthCheck();Debug Mode
Enable request/response logging for troubleshooting:
const client = new CortexClient({
baseUrl: "https://cortex.odin.4players.io",
apiKey: "ots_live_...",
debug: true,
});This logs every request and response (method, URL, status, body) to console.debug.
Error Handling
The SDK throws typed errors based on HTTP status codes:
import {
CortexAuthError, // 401, 403
CortexNotFoundError, // 404
CortexValidationError, // 422
CortexRateLimitError, // 429
CortexApiError, // Other HTTP errors
CortexError, // Base error class
} from "@4players/odin-cortex";
try {
await project.sessions.get("nonexistent-id");
} catch (err) {
if (err instanceof CortexNotFoundError) {
console.log("Session not found");
}
}Requirements
- Node.js 18+
- TypeScript 5.4+ (for type-checking)
License
MIT
