Skip to main content

Bot Module Context

The BotModuleContext object is passed as the first argument (ctx) to every bot module handler. It provides the account and sender, the resolved config, a per-install cache, external HTTP and background jobs.

Availability

ctx.db, ctx.cache, ctx.secrets, ctx.fetch, ctx.defer() and ctx.realtime work in every bot-module handler. ctx.db needs a version that declares a schema; without one every call throws ctx.db: this extension has no data schema.

ctx.auth - Account context​

The account (broadcaster/streamer) that installed the extension.

PropertyTypeDescription
ctx.auth.userIdstring | nullLumio user ID of the account owner
ctx.auth.accountIdstring | nullLumio account ID
ctx.auth.rolestringAlways "owner" for bot module contexts

For bot module handlers, userId and accountId are always non-null - the extension is installed on a specific account. The string | null type is a forward-compatibility measure.

ctx.user - Chat sender​

The viewer who triggered the handler. This is null for timer handlers and event handlers without a specific chat sender.

PropertyTypeDescription
ctx.user.idstringPlatform user ID of the sender
ctx.user.namestringUsername (login name)
ctx.user.displayNamestringDisplay name (may include capitalization, unicode)
ctx.user.platformstring"twitch", "youtube", "kick", or "discord"
ctx.user.rolestring"everyone", "follower", "subscriber", "vip", "moderator", or "broadcaster"
ctx.user.isModbooleanWhether the user is a moderator
ctx.user.isVipbooleanWhether the user is a VIP
ctx.user.isSubbooleanWhether the user is a subscriber

ctx.auth vs ctx.user​

ctx.authctx.user
WhoThe streamer/account ownerThe viewer in chat
When availableAlwaysnull for timers and some events
Use caseAccount-level lookups, configUser-specific replies, permissions

Example:

import { command } from "@zaflun/lumio-sdk/server";

export const whoami = command("whoami", async (ctx) => {
return {
reply: `You are ${ctx.user.name} (${ctx.user.role}) on ${ctx.auth.accountId}'s channel`,
};
});

ctx.config - Resolved configuration​

User-configurable settings defined in config_schema, with user overrides merged over extension defaults.

Config values are validated server-side against your config_schema before they reach the handler. You can safely access ctx.config.myField knowing it matches the declared type.

PropertyTypeDescription
ctx.configRecord<string, unknown>Key-value map of resolved config values
export const hello = command("hello", async (ctx) => {
const greeting = (ctx.config.greeting as string) ?? "Hello";
const showPoints = (ctx.config.showPoints as boolean) ?? true;
return { reply: `${greeting}, ${ctx.user.name}!` };
});

ctx.db - Extension database​

Persistent rows in the tables your version declares in server/schema.ts (see Schema). Every call is scoped to the install's account: a handler never sees or changes another account's rows.

MethodResolves toDescription
get(table, id)row or nullOne row by _id
list(table, filters?)rowsNewest first, at most 100 rows
insert(table, values)_id stringInserts a row; required columns must be set
patch(table, id, values)row or nullUpdates the given columns and _updated_at
delete(table, id)true / falseWhether a row was deleted

filters is a list of { field, op, value } with op one of eq, neq, gt, gte, lt, lte, for example [{ field: "score", op: "gte", value: 10 }] (typed as DbFilter from @zaflun/lumio-sdk/server). Rows carry the system columns _id, _account_id, _created_at and _updated_at.

export const points = command("points", async (ctx, args) => {
const user = ctx.user?.name ?? "anon";
const [row] = await ctx.db.list("points", [{ field: "user", op: "eq", value: user }]);
if (row) {
await ctx.db.patch("points", row._id as string, { score: (row.score as number) + 1 });
} else {
await ctx.db.insert("points", { user, score: 1 });
}
return { reply: `${user} has points`, actions: [] };
});

Limits:

  • 25 ctx.db calls per run.
  • Per installation and minute: 100 reads (get/list) and 200 writes (insert/patch/delete), shared with the extension's declarative server functions.
  • Each call is cancelled after 200 ms in command and moderate handlers and after 2 s elsewhere; when no database connection is free in time, it throws ctx.db: db busy.
  • When the rate-limit store is unreachable, reads still run and writes throw ctx.db: extension storage writes are unavailable.

ctx.cache - Scoped Redis cache​

Fast temporary storage backed by Redis. Keys are automatically scoped to the extension installation.

MethodSignatureDescription
get(key: string) => Promise<unknown | null>Read a cached value
set(key: string, value: unknown, ttl?: number) => Promise<void>Store a value (default TTL: 1 hour)
delete(key: string) => Promise<void>Remove a cached value
increment(key: string, by?: number, ttl?: number) => Promise<number>Atomically increment a numeric value (creates it at 0; ttl applies only when the key is created)
await ctx.cache.set("spam_score:user123", 5, 300); // 5-minute TTL
const score = await ctx.cache.get("spam_score:user123");
await ctx.cache.increment("message_count", 1);
await ctx.cache.delete("spam_score:user123");

Keys are [A-Za-z0-9:_-], at most 128 characters. Each install has at most 256 keys of at most 4 KB each; every key expires (default 1 hour, max 24 hours). A call over a limit throws. See Cache & Background Jobs for all limits and usage patterns.

ctx.secrets - Extension secrets​

Read a secret you stored with lumio env set or the developer dashboard (Extensions → your extension → Secrets):

const apiKey = await ctx.secrets.get("WEATHER_API_KEY");
if (apiKey === null) {
return { reply: "This module is not configured yet.", actions: [] };
}
  • Secrets belong to the extension, not to the streamer who installed it: every install reads the same values. Streamers never see or set them.
  • get(key) resolves to the value, or null when no secret with that key exists. Keys match ^[A-Z0-9_]{1,64}$; another key throws invalid secret key.
  • A changed or deleted secret reaches running handlers within 60 seconds.
  • If Lumio cannot read the secrets right now, the call throws ctx.secrets is unavailable. Catch it if your handler has a sensible fallback.
  • At most 10 ctx.secrets.get calls per handler run.
  • Never put a secret into a chat reply, a log line or ctx.cache.

ctx.fetch - External HTTP​

Make HTTPS requests to external APIs. Only hostnames declared in the egress.allowHosts manifest field are permitted.

const res = await ctx.fetch("https://api.example.com/data", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: "test" }),
});

const data = await res.json();

ctx.fetch returns a small response object, not a full Response: status, ok, headers (plain object), json() and text(). A string body is sent as-is; an object body is sent as JSON with Content-Type: application/json.

Restrictions:

  • HTTPS only, port 443 unless the manifest lists more in egress.allowPorts
  • Only hosts declared in egress.allowHosts; without an egress block every request is denied
  • Public addresses only: hosts that resolve to private, loopback or link-local addresses are refused
  • Redirects are not followed
  • 10 calls per handler run; 5-second timeout per request (never longer than the handler's remaining time)
  • Response body: at most 1 MB for command and moderate handlers, 10 MB for the others

A denied or failed request throws (for example ctx.fetch: egress denied: …).

ctx.defer() - Background jobs​

Queue a function that executes after the handler returns. The handler can respond immediately while expensive work runs in the background.

ParameterTypeDescription
fn() => Promise<void>Async function to run in the background
export const points = command("points", async (ctx) => {
const balance = (await ctx.cache.get(`pts:${ctx.user.id}`)) as number ?? 0;

ctx.defer(async () => {
await ctx.fetch("https://api.example.com/checks", {
method: "POST",
body: { user: ctx.user.id, balance },
});
});

return { reply: `${ctx.user.name}: ${balance} points` };
});

Deferred jobs run after the handler has returned (the reply is sent first) and only when the handler did not throw. Limits: 5 jobs per handler run, 10 seconds for all jobs of one run together, 10 jobs per minute per install. Jobs are not persisted. Errors are logged, never sent to chat. See Cache & Background Jobs for details.

ctx.realtime - Push to the extension's surfaces​

Send a message to every rendered surface of this install (its overlay and widget layers and the editor panel), where useRealtime receives it. A chat vote can update the overlay this way:

export const vote = command("vote", async (ctx, args) => {
await ctx.db.insert("votes", { choice: args[0], user: ctx.user?.id });
ctx.realtime.push("poll:votes", { choice: args[0] });
return { reply: `${ctx.user?.name} voted ${args[0]}` };
});

topic is 1-64 characters of A-Z a-z 0-9 _ . : - and starts with a letter or digit; payload is any JSON value of at most 16 KiB (omitted = null). Pushes are sent after the handler returns, only when it did not throw. Limits: 32 pushes per handler run (a push over the limit, a bad topic or a too large payload throws), 300 per install and minute shared with the extension's server functions (more are dropped). Nothing is stored or replayed.

Handler return types​

Command, keyword, pattern, timer, event handlers​

interface HandlerResponse {
reply?: string; // Text to send to chat
actions?: ActionRequest[]; // Additional platform actions
}

interface ActionRequest {
action: string;
params: Record<string, unknown>;
}

Return null for silent handling (no reply, no action).

actions run after the reply is sent, at most once each, through the same permission gate as widget actions: the manifest must declare each action name in permissions. An install may run at most 120 actions per minute; every outcome appears in the install's log. See Bot Modules → Actions.

Moderation handler​

interface ModerationResponse {
block: boolean; // Not read by the runtime - `action` decides
action?: "delete" | "timeout" | "ban";
duration?: number; // Timeout duration in seconds
reason?: string; // Passed to the platform with a timeout/ban; never stored in the audit log
}

defineModerate handlers return the same shape without block, plus "allow" and "warn":

interface ModerateResult {
action: "allow" | "delete" | "timeout" | "warn" | "ban";
duration?: number; // Timeout duration in seconds (default 600)
reason?: string; // Passed to the platform; never stored in the audit log
message?: string; // Chat text for "warn"
}

Lumio acts on the action field only. allow (or no action) lets the message through; delete, timeout, ban and warn become the chat actions chat:delete, chat:timeout (duration seconds, default 600), chat:ban and chat:send (message, else reason). The manifest must declare that action in permissions, or it is refused. block is not read: { block: true } without an action does nothing. The handler has a 500ms budget; a timeout or error lets the message through. See Bot Modules → Moderation.

Context availability by handler type​

Propertycommandkeywordpatterneventtimermoderate
ctx.authYesYesYesYesYesYes
ctx.userYesYesYesDependsnullYes
ctx.configYesYesYesYesYesYes
ctx.dbYesYesYesYesYesYes
ctx.cacheYesYesYesYesYesYes
ctx.secretsYesYesYesYesYesYes
ctx.fetchYesYesYesYesYesYes
ctx.deferYesYesYesYesYesYes
ctx.realtimeYesYesYesYesYesYes

For event handlers, ctx.user is populated when the event has a specific chat sender. For events like twitch:stream_online that have no sender, ctx.user is null.

Rate limits per install​

Besides the per-run call limits above, each installation has per-minute limits: 60 command runs, 100 keyword/pattern runs, 50 event runs, 200 cache operations and 10 deferred jobs. A trigger over its limit is skipped silently; a cache operation or deferred job over its limit throws or is skipped. Moderation and timer handlers are not rate-limited. The actions all handlers return share one cap of 120 per minute per install; actions over it are refused.