Skip to main content

Serverless Functions

Serverless Functions let you run your own TypeScript inside Cortex — triggered by domain events or invoked over HTTP — without standing up any infrastructure. They’re the right tool when you want custom logic close to your voice data: email a meeting summary, push a transcript to your CRM, escalate a toxic message, or expose a small HTTP endpoint.

You write the code in the dashboard editor (full IntelliSense), publish it, and Cortex transpiles, snapshots and deploys it to ODIN Fleet. Events are delivered to it automatically.

A function at a glance​

A function exports an HTTP handler and any number of event handlers:

// HTTP handler — called for HTTP requests to your function URL
exports.handler = async (event: HttpEvent, ctx: HttpContext) => {
return {
statusCode: 200,
body: { message: "Hello from your function!" },
};
};

// Event handler — the name maps 1:1 to the event, so exporting it subscribes
// the function automatically. No event list to maintain.
exports.onMessageCreated = async (event: MessageCreatedEvent, ctx: EventContext) => {
console.log("New message:", event.payload.data.content);
};

Named event handlers​

Subscribe to a domain event simply by exporting a handler named after it. The rule: split the event type on ., PascalCase each segment, and prefix on.

EventHandler export
message.createdonMessageCreated
participant.joinedonParticipantJoined
gathering.member.joinedonGatheringMemberJoined
session.annotation.createdonSessionAnnotationCreated

Named handlers exist for the user-facing event domains: session, message, participant, gathering, bot and sanction, plus the annotation events. Each handler’s event argument is the domain event itself (id, type, source, tenantId, projectId, resourceId, timestamp and an action-typed payload with action and data), and it is fully typed, so your editor knows the shape of event.payload.data. Exporting the handler is all it takes — there’s no separate subscription list to keep in sync.

Escape hatch: wildcards

For wildcard subscriptions (message.*, *) or events that have no named handler, export an eventScopes array plus a generic eventHandler. A function can mix both styles: a matching named handler wins, and eventHandler receives everything else it subscribed to.

exports.eventScopes = ["message.*"];
exports.eventHandler = async (event, ctx) => {
console.log(`Received: ${event.type}`, event.payload);
};

ctx.cortex — a pre-authenticated client​

Every handler receives a ctx whose ctx.cortex is a ready-to-use, project-scoped Cortex SDK client. No keys, no setup — call the API straight away:

exports.onSessionAnnotationCreated = async (event, ctx) => {
// Look up the session this annotation belongs to
const session = await ctx.cortex.sessions.get(event.payload.data.sessionId);
console.log("Session title:", session.title);
};

ctx.cortex is a project scope of the SDK — the same object you’d get from client.project(projectId) — so it exposes sessions, participants, gatherings, sanctions, plugins, webhooks, functions, settings and rooms. Manager methods return live objects, so fields like session.title are right on the result.

The runtime authenticates ctx.cortex for you and keeps the underlying credential out of reach of your code — you never handle a secret, and no API-key scopes apply. (Available on runtime v2 and newer.)

Push into the voice room​

On runtime v6, ctx.cortex.rooms.send() hands a message to the Cortex bot of a live session, which delivers it to the players in that ODIN room — for example, to announce that a lobby is ready without anyone polling:

exports.onGatheringMemberReady = async (event, ctx) => {
const gathering = await ctx.cortex.gatherings.get(event.payload.data.gatheringId);
if (gathering.sessionId) {
await ctx.cortex.rooms.send(gathering.sessionId, {
type: "lobby.ready",
data: { participantId: event.payload.data.member.participantId },
});
}
};

The session must be live (its bot in the room). Message types starting with cortex. are reserved, and the per-room limits apply (8 KB per message, 20 messages per second per room). See Room Push for how clients receive these messages.

Dependencies & environment variables​

Functions can declare npm dependencies and environment variables — configured per project. Declared packages are available to import/require, and their types are loaded into the editor so IntelliSense works.

await project.functions.updateDependencies({
dependencies: [{ name: "node-mailjet", version: "^6.0.0" }],
});

await project.functions.updateEnvVars({
envVars: [
{ key: "MJ_APIKEY_PUBLIC", value: "...", isSecret: true },
{ key: "MJ_APIKEY_PRIVATE", value: "...", isSecret: true },
],
});

Environment variables are injected into the runtime; read them with process.env. Mark credentials with isSecret: true — secret values are encrypted at rest and never shown again (the API returns them masked). updateEnvVars() replaces the whole list, so always send every variable the project needs. Names must start with a letter or underscore and contain only letters, digits and underscores. This is exactly what the “email a meeting summary” guide uses (node-mailjet + Mailjet credentials).

Reserved names

CORTEX_API_URL and CORTEX_PROJECT_SECRET are managed by the platform (they wire up ctx.cortex) and are removed before your code runs, so Cortex rejects them as environment variable names with a 400. Avoid the CORTEX_ prefix for your own variables — use ctx.cortex to call Cortex instead.

Preinstalled SDKs

The runtime image preinstalls @4players/odin-cortex and @4players/fleet, so you can import them without declaring them as dependencies. ctx.cortex is already an authenticated instance of the Cortex SDK.

The lifecycle: edit → publish → deploy​

  1. Activate Functions for the project once — Cortex provisions the Fleet resources it needs.
  2. Create & edit a function in the dashboard. The editor type-checks your TypeScript and blocks publishing while there are errors.
  3. Publish — Cortex transpiles your TypeScript to JavaScript, snapshots it as an immutable version, and analyzes which events it subscribes to.
  4. Deploy the version to Fleet. Cortex records the running endpoint and wires up event delivery.
const fn = await project.functions.create({ name: "on-summary", code: "/* ... */" });
await fn.publish({ changelog: "Initial version" });
const { invokeUrl } = await fn.deploy();
console.log("Invoke at:", invokeUrl);

Invoking over HTTP​

A deployed function is reachable at a per-project URL (returned as invokeUrl when you deploy, and shown in the dashboard):

https://cortex.odin.4players.io/invoke/{projectId}/{functionSlug}
https://cortex.odin.4players.io/invoke/{projectId}/{functionSlug}/any/sub/path?with=query

Any HTTP method works. The handler export receives the request — method, path (the sub path after the slug, or /), query (the parsed query string), headers, body (parsed JSON) and rawBody — and returns { statusCode, headers?, body }:

exports.handler = async (event: HttpEvent, ctx: HttpContext) => {
if (event.method === "GET" && event.path === "/lobbies") {
const region = event.query.region ?? "eu";
return { statusCode: 200, body: { region, lobbies: [] } };
}
return { statusCode: 404, body: { error: "Not found" } };
};

Each function has an auth mode that decides who may call it:

Auth modeHow to call it
publicNo credential — anyone with the URL.
api_keySend an API key (X-API-Key: ots_live_... or Authorization: Bearer ots_live_...). The key must belong to your account, carry the functions scope, and — if it is bound to a project — be bound to the function’s project.
signedSend X-Cortex-Signature: sha256=<hex>, an HMAC-SHA256 of the raw request body keyed with the function’s OTS_SIGNING_SECRET environment variable. For another service you control.

Runtime versions​

The function runtime is published as immutable versions (v1, v2, …) and each project is pinned to a specific one, so a platform update can never silently change your function’s behavior. New projects start on the latest version. When a newer runtime is available, the dashboard shows an upgrade option with its release notes; you upgrade when you’re ready.

VersionWhat it brings
v1First pinned runtime; structured errors that point at the failing source line.
v2Preinstalled @4players/odin-cortex and @4players/fleet SDKs and the pre-authenticated ctx.cortex.
v3Breaking: event handlers receive the bare domain event, so data is at event.payload.data.
v4, v5Do not use — these images fail to start. Upgrade to v6.
v6Current. Preinstalled SDK updated to @4players/odin-cortex 1.10, adding ctx.cortex.rooms.send(). Breaking coming from v3: ctx.cortex.voice and ctx.cortex.users are gone, and response shapes follow the current API. From v4/v5 it is a pure fix.

You can also check and upgrade from code:

const { current, latest, upgradeAvailable } = await project.functions.getRuntimeVersions();
if (upgradeAvailable) {
console.log(`Upgrading runtime ${current} → ${latest}`);
await project.functions.upgradeRuntime(latest);
}
Pinned to v4 or v5?

Functions on a project pinned to v4 or v5 don’t run at all. Upgrade to v6 — the upgrade restarts the runtime on the new image.

Errors & debugging​

When a handler throws, the response (HTTP or event) includes the error message, a trimmed stack, and a parsed location (file, line, column) pointing at the failing line in your original TypeScript — so you can jump straight to the problem. console.log output is captured per invocation.