Skip to main content

Handler-based Functions

Handler-based server functions use query(), mutation(), and action(). Their JavaScript runs in a V8 isolate on the Bot Module Worker, with access to the extension's tables through ctx.db.

When to use handler-based​

Use caseFunction type
Complex query with aggregationquery()
Conditional logic before writemutation()
Side effects, several writes in one callaction()

For a single read or write, prefer a declarative function: it runs on the API without an isolate.

query()​

A function that reads. Callers need read access (extension-data:read for a dashboard session or API key).

A query() handler cannot write: ctx.db.insert / patch / delete, ctx.cache.set / increment / delete and ctx.defer throw inside it. Put anything that writes in a mutation() or action().

// server/functions.ts
import { query, v } from "@zaflun/lumio-sdk/server";

export const getLeaderboard = query({
args: { limit: v.number() },
handler: async (ctx, args) => {
const rows = await ctx.db.list("votes");
return rows
.sort((a, b) => b.score - a.score)
.slice(0, Number(args.limit) || 10);
},
});

mutation()​

A function that writes. Callers need write access (extension-data:edit for a dashboard session or API key).

import { mutation, v } from "@zaflun/lumio-sdk/server";

export const castVote = mutation({
args: { ruleId: v.string(), choice: v.string() },
handler: async (ctx, args) => {
const existing = await ctx.db.list("votes", [
{ field: "ruleId", op: "eq", value: args.ruleId },
]);
if (existing.length > 0) {
await ctx.db.patch("votes", existing[0]._id, { choice: args.choice });
} else {
await ctx.db.insert("votes", { ruleId: args.ruleId, choice: args.choice });
}
return { success: true };
},
});

action()​

Same as mutation(), with a lower call rate (see Limits). Use it for work with side effects.

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

export const resetPoll = action({
args: {},
editorOnly: true,
handler: async (ctx) => {
for (const row of await ctx.db.list("votes")) {
await ctx.db.delete("votes", row._id);
}
return { cleared: true };
},
});

How a call runs​

  1. lumio build lists every export of server/functions.ts in dist/functions.json; a handler is listed as { "kind": "handler", "category": "query" | "mutation" | "action" }. The handler code goes into dist/server.js, also when the extension has a server/handler.ts.
  2. Your UI calls the function by name: useQuery("getLeaderboard", { limit: 5 }) or useMutation("castVote"). The params become the handler's args.
  3. The API looks the name up in the functions manifest of the version the install runs (pinned, else current). It checks the caller, editorOnly, the permission of the handler's category and the account's extension kill switch.
  4. The API sends the handler call with that version's server.js to the Bot Module Worker. The code always comes from the published version, never from the request.
  5. The worker runs handler(ctx, args) in its own isolate and returns the value. Your hook gets it as data.

Directly, the same call is POST /v1/extension-installs/{install_id}/execute/{function_name} with { "args": { … } } (REST), or GraphQL extensionExecuteHandler(input: { installId, functionName, args }). Both answer { data }: the handler's return value.

Who may call​

The caller is decided by the credential, never by the request:

CallerSurfaceRuns
Dashboard session, API keyeditorquery() with extension-data:read, mutation() / action() with extension-data:edit
System keyeditorwith the bots:internal grant
Extension, overlay or widget tokenlayerthe install the token is bound to, never an editorOnly handler

Shared overlay links and popout tokens cannot call server functions. While the account's extension kill switch is on, every handler call is refused.

The ctx object​

ctx.db​

The extension's tables, limited to the install's account (see Storage Scoping). It is the same data executor as the declarative functions and bot-module ctx.db.

const row = await ctx.db.get("rules", id); // row or null
const rows = await ctx.db.list("rules", [ // filters optional
{ field: "revealed", op: "eq", value: true },
]);
const inserted = await ctx.db.insert("rules", { text: "No jumping", revealed: false });
await ctx.db.patch("rules", id, { revealed: true });
await ctx.db.delete("rules", id);

Filter operators: eq, neq, gt, gte, lt, lte, like, in.

ctx.auth​

PropertyTypeDescription
userIdstring | nullThe Lumio user of a dashboard session or API key; null for extension, overlay and widget tokens and for System keys
accountIdstringAccount that owns the install
role"user" | "api" | "system" | "layer"Kind of caller: session, API key, System key, or a rendered extension

ctx.config​

The install's saved configuration.

ctx.fetch​

Server functions have no egress allow-list yet: ctx.fetch refuses every host. Call external APIs from a bot module instead.

ctx.secrets​

const apiKey = await ctx.secrets.get("MY_API_KEY"); // string | null

Reads the extension's owner secrets, like bot-module handlers. See Secrets.

ctx.cache​

Key-value cache scoped to the install, shared with the extension's bot-module handlers. See Cache & Background Jobs.

await ctx.cache.set("counter", 42, 3600);
const val = await ctx.cache.get("counter");
await ctx.cache.increment("counter", 1);
await ctx.cache.delete("counter");

ctx.defer​

Queue a function to run after the handler has returned. At most 5 per call, 10 s in total. See Cache & Background Jobs.

ctx.realtime​

Push a message to every rendered surface of this install - the editor panel and every overlay or widget that shows it. The surfaces receive it with useRealtime.

export const castVote = mutation({
args: { choice: v.string() },
handler: async (ctx, args) => {
await ctx.db.insert("votes", { choice: args.choice });
const votes = await ctx.db.list("votes");
ctx.realtime.push("poll:votes", { total: votes.length });
},
});
  • topic: 1-64 characters of A-Z a-z 0-9 _ . : -, starting with a letter or digit.
  • payload: any JSON value, at most 16 KiB serialized. Omitted, it is null.
  • push only collects. The messages are sent after the handler returns, in call order, and only if it did not throw.
  • At most 32 pushes per call. A push that breaks a rule throws in the handler.
  • At most 300 pushes per install and minute, together with the install's bot-module handlers. Pushes over that are dropped; the call still succeeds.
  • Nothing is stored or replayed: a surface that is not open when you push does not get the message.

Limits​

LimitValue
Wall-clock time per call5 s
Heap per call256 MB
ctx.db calls per call25 (each also counts against the install's data read/write limits)
ctx.cache calls per call50
ctx.secrets.get calls per call10
Calls per install and minutequery() 100 (shared with declarative reads), mutation() 50, action() 20
Handlers running at the same time16 per worker replica, 4 per account; a call that finds no free slot fails at once with RATE_LIMIT_EXCEEDED
Size of server.js5 MiB
ctx.realtime.push per call32 (payload at most 16 KiB each)
ctx.realtime.push per install and minute300 (shared with bot-module handlers; more are dropped)

Errors​

StatusCodeWhen
400BAD_REQUESTThe name is a declarative function (Server function '{name}' is not a handler; call it through query or mutate)
403FORBIDDENMissing permission, an editorOnly handler from a token, a token for another install
403EXTENSIONS_KILLEDThe account's extension kill switch is on
404NOT_FOUNDUnknown install or function, or the version has no server.js
422HANDLER_ERRORThe handler threw; the message is the first line of the error it threw (at most 500 characters). A failure outside your code (for example the isolate could not start) answers Handler execution failed
422HANDLER_MEMORY_LIMITThe handler ran past its heap limit
429RATE_LIMIT_EXCEEDEDOver the install's calls per minute
503EXECUTOR_UNAVAILABLEThe Bot Module Worker cannot run handlers right now; try again
504HANDLER_TIMEOUTThe handler ran past 5 s

In your UI, useQuery / useMutation report every one of these as error.

Validators​

Use v (from @zaflun/lumio-sdk/server) to type args:

import { action, v } from "@zaflun/lumio-sdk/server";

export const example = action({
args: {
name: v.string(),
count: v.number(),
optionalNote: v.string().optional(),
},
handler: async (ctx, args) => {
if (typeof args.name !== "string") throw new Error("name must be a string");
// ...
},
});

The validators type args in your editor. The worker does not check them yet: args is the JSON the caller sent, so check every value your handler relies on.

Bot module handlers​

Bot module extensions use a different set of handler wrappers optimized for chat interaction. These wrappers set a __type discriminant on the exported function, which the CLI reads during bundling to register the handler type.

Every bot module handler except moderate() may return { reply?: string, actions?: ActionRequest[] } or null. The reply is sent first; the actions ({ action, params }, at most 20) run afterwards, at most once, through the same permission gate as widget actions - the manifest must declare each action name in permissions, and the account's plan must include its feature. Each install may run at most 120 actions per minute; every outcome appears in the install's log. See Bot Modules → Actions.

command()​

Handles chat commands triggered by a prefix (e.g., !points). Runs on the sync execution path with a 500ms deadline.

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

export const points = command("points", async (ctx, args) => {
const balance = await ctx.db.get("user_points", ctx.user.id);
return { reply: `${ctx.user.name} has ${balance?.balance ?? 0} points` };
});

Parameters:

  • name - command name (matches triggers.commands[].name in the manifest)
  • handler(ctx, args) - async function receiving BotModuleContext and a string[] of parsed arguments

Returns: { reply?: string, actions?: ActionRequest[] } or null

keyword()​

Fires when a substring match is found in a chat message. Runs on the async execution path.

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

export const hypeDetector = keyword("gg", async (ctx, message) => {
await ctx.cache.increment("hype_count", 1);
return null;
});

Parameters:

  • word - keyword string (matches triggers.keywords[] in the manifest)
  • handler(ctx, message) - async function receiving context and the chat message object

pattern()​

Fires when a regex pattern matches in a chat message. The pattern is matched by the Rust bot - JavaScript RegExp is never used for trigger matching.

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

export const clipLogger = pattern(
"https?://clips\\.twitch\\.tv/\\S+",
async (ctx, message, matched) => {
return { reply: `Clip from ${ctx.user.name}: ${matched}` };
}
);

Parameters:

  • regex - regex pattern string (matches triggers.patterns[] in the manifest)
  • handler(ctx, message, matched) - async function receiving context, message, and the matched substring

event()​

Fires when a platform event occurs (subscriptions, raids, donations, etc.).

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

export const welcomeSub = event("twitch:subscribe", async (ctx, evt) => {
return { reply: `Welcome ${evt.user_name}! Thanks for subscribing!` };
});

Parameters:

  • eventType - event type string (matches triggers.events[] in the manifest)
  • handler(ctx, evt) - async function receiving context and the event payload - the same payload widgets receive for that event

The handler runs once per matching event of the installing account. A reply goes to the chat of the platform the event came from (Twitch, Kick, YouTube); Discord events have no chat channel, so a reply to them is dropped.

timer()​

Fires at a configured interval. Timer handlers have no chat sender - ctx.user is null.

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

export const periodicReminder = timer("reminder", async (ctx) => {
return { reply: "Remember to follow the channel!" };
});

Parameters:

  • name - timer name (matches triggers.timers[].name in the manifest)
  • handler(ctx) - async function receiving context only

The timer runs once per interval seconds per install, however many worker replicas run; a missed interval is skipped, not caught up. A reply goes to every chat platform the install runs on, except Discord. An install whose config sets onlyWhenLive: true runs only while at least one of those platforms is live, and replies only on the live ones. defineTimer(handler) as the default export is the single-timer form (manifest "triggers": { "timer": true }): the timer is named timer and its interval is the install's intervalSeconds setting (60 s - 24 h, default 300 s).

moderate()​

Runs on every chat message of the account on Twitch, Kick and YouTube, when the manifest sets "triggers": { "moderation": true }. Messages from the broadcaster are not moderated; moderator messages arrive with ctx.user.isMod set, so the handler decides whether to exempt them, and Discord messages are not passed to moderate handlers (no chat action supports Discord). Each call has a 500ms budget and never holds chat up: a timeout, an error or a saturated account (16 checks in flight) lets the message through.

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

export default defineModerate(async (ctx) => {
if (ctx.message.text.includes("banned-site.com")) {
return { action: "timeout", duration: 60, reason: "Banned link" };
}
return { action: "allow" };
});

The low-level moderate(async (ctx, message) => …) wrapper takes the same return value; only action decides what happens (a block field is ignored).

Parameters:

  • handler(ctx) - async function receiving context with ctx.message (defineModerate), or handler(ctx, message) (moderate)

Returns: { action: "allow" | "delete" | "timeout" | "ban" | "warn", duration?: number, reason?: string, message?: string }

Lumio turns the decision into a chat action. The manifest must declare that action in permissions, otherwise it is refused:

DecisionActionPermission
allownone-
deletechat:delete on the messagechat:delete
timeoutchat:timeout for the sender, duration seconds (default 600)chat:timeout
banchat:ban for the senderchat:ban
warnchat:send with message (or reason)chat:send

The action counts against the install's 120 actions per minute and appears in its log; an allow writes nothing.

See Bot Module Context for the full ctx API reference.