Skip to main content

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 caseFunction type
Complex query with aggregationquery()
Conditional logic before writemutation()
External HTTP API callsaction()
Reading secretsaction()

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:

PropertyTypeDescription
userIdstring | nullLumio user ID, or null for unauthenticated
accountIdstringAccount 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:

LimitValue
Memory128 MB per invocation
CPU timeout5 seconds
Response size4 MB
ctx.fetch calls per invocation10
Concurrent invocations per installation5

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 (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

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[].handler in 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.