Skip to main content

Real-time updates

Cortex pushes changes to you over Server-Sent Events. The SDK wraps this in a Firestore-style watch() API: you get the current state immediately (over REST) and then a live stream of changes — with reconnection handled for you. No polling, no manual EventSource, no merge logic.

Watch a live transcript​

The most common real-time use case: render a transcript as people speak.

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

const client = new CortexClient({
baseUrl: "https://cortex.odin.4players.io",
apiKey: process.env.CORTEX_API_KEY, // scopes: sessions + messages
});

const project = client.project("your-project-id");

// Watch the session itself (status changes, etc.)
const sessionSub = project.sessions.watch(sessionId);
sessionSub.onSnapshot((session) => {
if (!session) {
console.log("[session] deleted or not found");
return;
}
console.log(`[session] ${session.title} — status=${session.status}`);
});

// Watch the transcript: initial messages via REST, new ones streamed live.
const session = await project.sessions.get(sessionId);
const messageSub = session.watchMessages();
messageSub.onSnapshot((messages, changes) => {
for (const change of changes) {
if (change.type === "added") {
console.log(`+ ${change.data.senderName}: ${change.data.content}`);
}
}
if (changes.length === 0) {
console.log(`[transcript] ${messages.length} message(s) loaded`);
}
});

process.on("SIGINT", () => {
sessionSub.unsubscribe();
messageSub.unsubscribe();
process.exit(0);
});

Each snapshot gives you the full current array plus the changes that triggered it, so you can either re-render everything or apply incremental updates.

Scopes decide what you receive

With an API key, the stream only carries events for the resource families the key’s scopes cover. Watching sessions needs sessions; watching a transcript also needs messages. If a watcher never fires, check the key’s scopes first.

Watch a collection​

Watch every session in a project and react as they’re created, started or stopped:

const sub = project.sessions.watch();

sub.onSnapshot((sessions, changes) => {
console.log(`\n[snapshot] ${sessions.length} session(s)`);
for (const change of changes) {
// change.type is "added" | "modified" | "removed"
console.log(` ${change.type.padEnd(8)} ${change.id} (${change.data.status})`);
}
});

sub.onError((err) => console.error("[error]", err));

// Read the latest value synchronously at any time:
console.log(sub.current);

The same pattern works for project.participants.watch(), project.sanctions.watch(), project.gatherings.watch() and project.webhooks.watchEvents().

Watch nested collections​

Some collections live on a live object you fetch first:

const gathering = await project.gatherings.get(gatheringId);
gathering.watchMembers().onSnapshot((members) => { /* member states */ });
gathering.watchInvitations().onSnapshot((invites) => { /* invitation states */ });

Resilience​

  • All watchers in a project share a single SSE connection (closed when the last one unsubscribes).
  • The connection auto-reconnects with backoff, and on every reconnect each subscription re-fetches its snapshot — so a dropped connection never leaves you with stale data.
  • An idle-timeout guards against dead sockets; you can tune it via realtime.idleTimeoutMs on the client (default 45s, 0 to disable).
const client = new CortexClient({
baseUrl,
apiKey,
realtime: { idleTimeoutMs: 45_000 },
});

watch() works with both API-key and JWT auth, and long-lived streams with an API key are fully supported. Credentials always travel in request headers — the SDK streams over fetch rather than EventSource, which is why it requires a WHATWG fetch (the global fetch on Node 18+ or any modern browser). Each (re)connect counts against the key’s rate limit; the SDK backs off between attempts.

Polling instead of streaming​

Sometimes a long-lived stream isn’t an option — a serverless backend, a cron job, or a game server that checks in every few seconds. The transcript endpoint is built for that: every message has a per-session sequence number (seq), so you can ask for “everything after the last message I saw” and let the server tell you cheaply when there is nothing new.

const session = await project.sessions.get(sessionId);
let after = 0; // the last seq you've processed (persist it if your poller restarts)

async function poll() {
const page = await session.listMessages(
{ after, limit: 200 },
{ ifNoneMatch: `"${after}"` }, // → null (HTTP 304) while nothing new arrived
);
if (!page) return; // nothing new

for (const m of page.messages) {
console.log(`#${m.seq} ${m.senderName}: ${m.content}`);
after = m.seq; // advance past what you've processed
}
if (page.nextCursor !== null) await poll(); // more than one page waiting — keep reading
}

setInterval(poll, 2000);

How it works:

  • after returns only messages with a higher seq; limit is 1–200 (default 50). Use order: "desc" to get the newest page first instead (no continuation in that order).
  • The response carries latestSeq — the session’s newest seq — and sends it as the ETag. When more messages are waiting than fit in one page, nextCursor is set; keep reading from the last seq you processed until it is null. Passing it back as ifNoneMatch (If-None-Match over raw HTTP) makes the server answer 304 Not Modified without touching the database while nothing changed.
  • include: ["annotations"] attaches plugin annotations (e.g. toxicity) to each message, and include: ["debugAudio"] the hasDebugAudio flag. Both are omitted unless you ask. Because annotations arrive asynchronously, no ETag is sent when you include them.
  • A 304 still counts as a request against your rate limit. A poll every second or two per session is fine; for many sessions at once, prefer the stream.

The same over raw HTTP:

GET /api/projects/{projectId}/sessions/{sessionId}/messages?after=180&limit=200
X-API-Key: ots_live_...
If-None-Match: "180"

Don’t need the SDK?​

You can consume the raw SSE stream directly — see Events & Real-time. Send your API key in a header (X-API-Key); keys are not accepted as a URL query parameter. For durable, retried delivery, use webhooks instead.