Handler-based Functions
Handler-based server functions use query(), mutation(), and action(). They run JavaScript inside a V8 isolate with access to the database, secrets, and (for actions) external HTTP APIs.
When to use handler-based
| Use case | Function type |
|---|---|
| Complex query with aggregation | query() |
| Conditional logic before write | mutation() |
| External HTTP API calls | action() |
| Reading secrets | action() |
query()
A read-only function. Has access to ctx.db but not ctx.fetch or ctx.secrets.
// 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.query("votes", {
orderBy: "score",
orderDir: "desc",
limit: args.limit,
});
return rows;
},
});
mutation()
A read-write function. Has access to ctx.db and ctx.auth. Can read and write rows conditionally.
import { mutation, v } from "@zaflun/lumio-sdk/server";
export const castVote = mutation({
args: { ruleId: v.string(), choice: v.string() },
handler: async (ctx, args) => {
const userId = ctx.auth.userId;
if (!userId) throw new Error("Must be authenticated to vote");
// Check for existing vote
const existing = await ctx.db.get("votes", {
filter: { userId, ruleId: args.ruleId },
});
if (existing) {
// Update existing vote
await ctx.db.patch("votes", existing.id, { choice: args.choice });
} else {
// Insert new vote
await ctx.db.insert("votes", {
userId,
ruleId: args.ruleId,
choice: args.choice,
});
}
return { success: true };
},
});
action()
A full-capability function. Has access to ctx.db, ctx.auth, ctx.fetch, and ctx.secrets. Suitable for external API calls.
import { action, v } from "@zaflun/lumio-sdk/server";
export const getScoreboard = action({
args: { sport: v.string(), league: v.string() },
handler: async (ctx, args) => {
const apiKey = ctx.secrets.get("ESPN_API_KEY");
const res = await ctx.fetch(
`https://site.api.espn.com/apis/site/v2/sports/${args.sport}/${args.league}/scoreboard`,
{
headers: { "Authorization": `Bearer ${apiKey}` },
}
);
if (!res.ok) {
throw new Error(`ESPN API error: ${res.status}`);
}
const data = await res.json();
return data.events.slice(0, 5);
},
});
The ctx object
All handler-based functions receive a ctx (context) object as the first argument:
ctx.db
Database access for the extension's isolated schema:
// Get a single row by ID
const row = await ctx.db.get("rules", "some-uuid");
// Query rows with filter
const rows = await ctx.db.query("rules", {
filter: { revealed: true },
orderBy: "created_at",
limit: 10,
});
// Insert a row
const newRow = await ctx.db.insert("rules", {
text: "Complete the game without dying",
revealed: false,
});
// Update fields
await ctx.db.patch("rules", row.id, { revealed: true });
// Delete a row
await ctx.db.delete("rules", row.id);
ctx.auth
Identity information for the caller:
| Property | Type | Description |
|---|---|---|
userId | string | null | Lumio user ID, or null for unauthenticated |
accountId | string | Account that owns this installation |
role | "owner" | "editor" | "viewer" | "anonymous" | Caller's role |
surface | "editor" | "layer" | "interactive" | Which surface called this function |
ctx.fetch
An egress-restricted version of fetch(). Only URLs matching the extension's egress allowlist are permitted:
// Works if site.api.espn.com is in the allowlist
const res = await ctx.fetch("https://site.api.espn.com/...");
// Rejected: not in allowlist
const res = await ctx.fetch("https://malicious.example.com/..."); // throws
See External APIs for allowlist configuration.
ctx.secrets
Access secrets stored in the extension dashboard:
const apiKey = ctx.secrets.get("MY_API_KEY"); // returns string | null
See Secrets for management instructions.
ctx.cache
Scoped Redis key-value cache available to all server functions. Faster than ctx.db for temporary data.
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");
Keys are scoped per install (ext-cache:\{install_id\}:*). Max TTL 24h, default 1h. See Cache & Background Jobs.
ctx.defer
Queue a function to run after the handler returns — useful for background DB writes after a fast cache read.
ctx.defer(async () => {
await ctx.db.patch("stats", id, { count: newCount });
});
Max 5 deferred calls per handler invocation. Each has a 10s timeout. See Cache & Background Jobs.
Sandbox limits
Handler-based functions run in a V8 isolate with these constraints:
| Limit | Value |
|---|---|
| Memory | 128 MB per invocation |
| CPU timeout | 5 seconds |
| Response size | 4 MB |
ctx.fetch calls per invocation | 10 |
| Concurrent invocations per installation | 5 |
Exceeding any limit terminates the invocation and returns an error to the client.
Validators
Use v (from @zaflun/lumio-sdk/server) to type-check function arguments:
import { action, v } from "@zaflun/lumio-sdk/server";
export const example = action({
args: {
name: v.string(),
count: v.number(),
enabled: v.boolean(),
tags: v.array(v.string()),
config: v.object({ x: v.number(), y: v.number() }),
optionalNote: v.string().optional(),
},
handler: async (ctx, args) => {
// args is fully typed: { name: string, count: number, enabled: boolean, ... }
},
});
Arguments that do not match the declared validators are rejected before the handler runs.
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.
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
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[].handlerin the manifest)handler(ctx)— async function receiving context only
moderate()
Runs on every chat message that passes built-in module checks. Uses the sync execution path with a 500ms deadline. Requires chat:ban or chat:delete permission.
import { moderate } from "@zaflun/lumio-sdk/server";
export const linkFilter = moderate(async (ctx, message) => {
if (message.text.includes("banned-site.com")) {
return { block: true, action: "timeout", duration: 60, reason: "Banned link" };
}
return { block: false };
});
Parameters:
handler(ctx, message)— async function receiving context and the chat message
Returns: { block: boolean, action?: "delete" | "timeout" | "ban", duration?: number, reason?: string }
See Bot Module Context for the full ctx API reference.