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 case | Function type |
|---|---|
| Complex query with aggregation | query() |
| Conditional logic before write | mutation() |
| Side effects, several writes in one call | action() |
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
lumio buildlists every export ofserver/functions.tsindist/functions.json; a handler is listed as{ "kind": "handler", "category": "query" | "mutation" | "action" }. The handler code goes intodist/server.js, also when the extension has aserver/handler.ts.- Your UI calls the function by name:
useQuery("getLeaderboard", { limit: 5 })oruseMutation("castVote"). The params become the handler'sargs. - 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. - The API sends the handler call with that version's
server.jsto the Bot Module Worker. The code always comes from the published version, never from the request. - The worker runs
handler(ctx, args)in its own isolate and returns the value. Your hook gets it asdata.
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:
| Caller | Surface | Runs |
|---|---|---|
| Dashboard session, API key | editor | query() with extension-data:read, mutation() / action() with extension-data:edit |
| System key | editor | with the bots:internal grant |
| Extension, overlay or widget token | layer | the 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
| Property | Type | Description |
|---|---|---|
userId | string | null | The Lumio user of a dashboard session or API key; null for extension, overlay and widget tokens and for System keys |
accountId | string | Account 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 ofA-Z a-z 0-9 _ . : -, starting with a letter or digit.payload: any JSON value, at most 16 KiB serialized. Omitted, it isnull.pushonly 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
| Limit | Value |
|---|---|
| Wall-clock time per call | 5 s |
| Heap per call | 256 MB |
ctx.db calls per call | 25 (each also counts against the install's data read/write limits) |
ctx.cache calls per call | 50 |
ctx.secrets.get calls per call | 10 |
| Calls per install and minute | query() 100 (shared with declarative reads), mutation() 50, action() 20 |
| Handlers running at the same time | 16 per worker replica, 4 per account; a call that finds no free slot fails at once with RATE_LIMIT_EXCEEDED |
Size of server.js | 5 MiB |
ctx.realtime.push per call | 32 (payload at most 16 KiB each) |
ctx.realtime.push per install and minute | 300 (shared with bot-module handlers; more are dropped) |
Errors
| Status | Code | When |
|---|---|---|
400 | BAD_REQUEST | The name is a declarative function (Server function '{name}' is not a handler; call it through query or mutate) |
403 | FORBIDDEN | Missing permission, an editorOnly handler from a token, a token for another install |
403 | EXTENSIONS_KILLED | The account's extension kill switch is on |
404 | NOT_FOUND | Unknown install or function, or the version has no server.js |
422 | HANDLER_ERROR | The 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 |
422 | HANDLER_MEMORY_LIMIT | The handler ran past its heap limit |
429 | RATE_LIMIT_EXCEEDED | Over the install's calls per minute |
503 | EXECUTOR_UNAVAILABLE | The Bot Module Worker cannot run handlers right now; try again |
504 | HANDLER_TIMEOUT | The 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 (matchestriggers.commands[].namein the manifest)handler(ctx, args)- async function receivingBotModuleContextand astring[]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 (matchestriggers.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 (matchestriggers.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 (matchestriggers.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 (matchestriggers.timers[].namein 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 withctx.message(defineModerate), orhandler(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:
| Decision | Action | Permission |
|---|---|---|
allow | none | - |
delete | chat:delete on the message | chat:delete |
timeout | chat:timeout for the sender, duration seconds (default 600) | chat:timeout |
ban | chat:ban for the sender | chat:ban |
warn | chat: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.