`.
```typescript
interface BoxProps {
style?: React.CSSProperties;
children?: React.ReactNode;
onClick?: () => void;
ref?: React.Ref
;
[key: string]: unknown; // data-* attributes passed through
}
```
---
### Text
Renders inline text with semantic variants.
```typescript
interface TextProps {
content: string;
variant?: "default" | "heading" | "muted" | "label" | "code";
style?: React.CSSProperties;
}
```
---
### Image
Renders an image with automatic HTTPS enforcement and lazy loading.
```typescript
interface ImageProps {
src: string;
alt: string;
style?: React.CSSProperties;
onError?: () => void;
}
```
---
### Input
A text input field for use in the editor panel.
```typescript
interface InputProps {
label?: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
type?: "text" | "email" | "url" | "password" | "number";
disabled?: boolean;
}
```
---
### Select
A dropdown select field for use in the editor panel.
```typescript
interface SelectProps {
label?: string;
value: string;
options: Array<{ value: string; label: string }>;
onChange: (value: string) => void;
disabled?: boolean;
}
```
---
### Toggle
A boolean toggle switch for use in the editor panel.
```typescript
interface ToggleProps {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}
```
---
### Button
An interactive button for use in the editor panel or interactive page.
```typescript
interface ButtonProps {
label: string;
onClick: () => void;
variant?: "primary" | "secondary" | "danger";
loading?: boolean;
disabled?: boolean;
style?: React.CSSProperties;
}
```
---
## Animation utilities
### defineKeyframes
Registers a CSS keyframe animation and returns a unique name safe for use in `animation:` CSS properties.
```typescript
function defineKeyframes(frames: Record): string;
```
```typescript
const slideIn = defineKeyframes({
from: { opacity: 0, transform: "translateY(-20px)" },
to: { opacity: 1, transform: "translateY(0)" },
});
// Use in a Box style:
style={{ animation: `${slideIn} 0.4s ease forwards` }}
```
---
### localFont
Loads a font file bundled with the extension and returns a CSS `font-family` value.
```typescript
function localFont(options: { src: string; weight?: string; style?: string }): string;
```
```typescript
const myFont = localFont({ src: "./assets/CustomFont.woff2", weight: "400 700" });
...
```
---
## API Reference / SDK Server Reference
# SDK Server Reference
Complete reference for `@zaflun/lumio-sdk/server` — the server-side library used in `server/functions.ts`.
## Import
```typescript
import { action, query, v } from "@zaflun/lumio-sdk/server";
```
---
## Defining functions
### action
Defines a server function that can be called by the client via `useLumioAction()` or `useMutation()`. Actions have full access to storage, secrets, egress, and real-time push.
```typescript
function action(definition: {
args: ArgSchema;
handler: (ctx: ActionContext, args: TArgs) => Promise;
}): Action;
```
```typescript
export const incrementScore = action({
args: { amount: v.number() },
handler: async (ctx, args) => {
const current = (await ctx.db.get("install", "score")) ?? 0;
const next = current + args.amount;
await ctx.db.set("install", "score", next);
return { score: next };
},
});
```
---
### query
Defines a read-only server function. Queries cannot write to storage, push real-time events, or call external APIs. They are intended for fetching state and may be cached by the platform.
```typescript
function query(definition: {
args: ArgSchema;
handler: (ctx: QueryContext, args: TArgs) => Promise;
}): Query;
```
```typescript
export const getScore = query({
args: { userId: v.string() },
handler: async (ctx, args) => {
return ctx.db.get("user", args.userId, "score") ?? 0;
},
});
```
---
## ActionContext
The `ctx` parameter in `action()` handlers.
### ctx.db — Storage
Reads and writes extension storage, namespaced by scope.
```typescript
// Read a value
const value = await ctx.db.get(scope, ...keys);
// Write a value
await ctx.db.set(scope, ...keys, value);
// Delete a value
await ctx.db.delete(scope, ...keys);
// List all keys in a scope (with optional prefix)
const keys = await ctx.db.list(scope, prefix?);
```
**Scopes:**
| Scope | Isolation | Capacity |
|-------|-----------|---------|
| `"global"` | Shared across all installs | 100 MB |
| `"install"` | Per overlay install | 10 MB |
| `"user"` | Per authenticated viewer (interactive page only) | 1 MB |
---
### ctx.fetch — External HTTP
Calls an external HTTPS endpoint. Subject to the `egress.allowHosts` allowlist declared in `lumio.config.json`.
```typescript
const res = await ctx.fetch("https://api.example.com/data");
const json = await res.json();
```
Has the same interface as the standard `fetch()` API. Restrictions:
- HTTPS only
- No private IP ranges or localhost
- Maximum 10 calls per action invocation
- 30-second timeout per request
---
### ctx.secrets — Secret access
Reads secrets stored via `lumio secrets set` or the dashboard.
```typescript
const apiKey = ctx.secrets.get("MY_API_KEY"); // throws if not set
const exists = ctx.secrets.has("MY_API_KEY"); // boolean check
```
Secret values are never logged or returned to the client.
---
### ctx.realtime — Push real-time events
Pushes an event to all connected surfaces (layer, editor, interactive page) for this install.
```typescript
await ctx.realtime.push("score:updated", { score: 42 });
```
Clients receive the event via a WebSocket subscription. Listen with:
```typescript
window.addEventListener("lumio:realtime", (event) => {
if (event.type === "score:updated") {
console.log(event.data.score);
}
});
```
Maximum 64 KB payload. Maximum 100 pushes/second per extension.
---
### ctx.cache — Scoped cache
Scoped Redis key-value cache. Faster than `ctx.db` for temporary data that can be lost.
```typescript
await ctx.cache.set("counter", 42, 3600); // set with TTL in seconds
const val = await ctx.cache.get("counter"); // get value
await ctx.cache.increment("counter", 1); // atomic increment
await ctx.cache.delete("counter"); // delete key
```
Keys are scoped per install (`ext-cache:{install_id}:*`). TTL range 1s–24h, default 1h. See [Cache & Background Jobs](/guides/cache-and-defer).
---
### ctx.defer — Background work
Queue a function to run after the handler returns — useful for background DB writes after a fast cache read.
```typescript
ctx.defer(async () => {
await ctx.db.set("install", "last_run", Date.now());
});
```
Max 5 deferred calls per handler invocation. Each has a 10s timeout. See [Cache & Background Jobs](/guides/cache-and-defer).
---
### ctx.identity — Caller identity
Present when the action is called from the interactive page by an authenticated viewer. `null` on the layer and editor surfaces.
```typescript
if (ctx.identity) {
console.log(ctx.identity.userId);
console.log(ctx.identity.platform); // "twitch" | "youtube" | ...
console.log(ctx.identity.platformUserId);
}
```
---
## QueryContext
The `ctx` parameter in `query()` handlers. A subset of `ActionContext` — read-only access only.
| Property | Available |
|----------|----------|
| `ctx.db.get` | Yes |
| `ctx.db.list` | Yes |
| `ctx.db.set` | No — throws |
| `ctx.fetch` | No — throws |
| `ctx.secrets` | Yes (read-only) |
| `ctx.realtime` | No — throws |
| `ctx.identity` | Yes |
---
## Validation — `v`
The `v` object provides runtime argument validators. All args must be declared with `v` — undeclared fields are stripped before the handler runs.
```typescript
// Primitives
v.string()
v.number()
v.boolean()
v.null_()
// Modifiers
v.optional(v.string()) // string | undefined — field may be absent
v.nullable(v.string()) // string | null
// Collections
v.array(v.string())
v.object({ name: v.string(), count: v.number() })
// Union
v.union([v.string(), v.number()])
// Literal
v.literal("active")
```
Validation happens before the handler is called. Invalid args return a structured error to the caller without invoking the handler.
---
## Exports from `server/functions.ts`
Every named export from `server/functions.ts` that is an `action()` or `query()` result is automatically registered with the platform at deploy time. No additional registration step is needed.
```typescript
// server/functions.ts
export const getScore = query({ ... }); // registered as "getScore"
export const setScore = action({ ... }); // registered as "setScore"
// Not registered — not an action or query
export const MULTIPLIER = 2;
```
The exported name becomes the `actionName` / `queryName` used on the client.
---
## Bot module handlers
Bot module extensions (`category: "bot_module"`) use a separate set of handler wrappers for chat interaction. Each wrapper sets a `__type` discriminant on the exported function, following the same pattern as `query()` and `action()`.
### Handler type reference
| Wrapper | `__type` | `__trigger` | Sync/Async | Timeout |
|---------|----------|-------------|------------|---------|
| `command(name, handler)` | `"command"` | Command name | Sync | 500ms |
| `keyword(word, handler)` | `"keyword"` | Keyword string | Async | 10s |
| `pattern(regex, handler)` | `"pattern"` | Regex string | Async | 10s |
| `event(type, handler)` | `"event"` | Event type | Async | 10s |
| `timer(name, handler)` | `"timer"` | Timer name | Async | 10s |
| `moderate(handler)` | `"moderate"` | N/A | Sync | 500ms |
### Signatures
```typescript
function command(
name: string,
handler: (ctx: BotModuleContext, args: string[]) => Promise
): CommandDefinition;
function keyword(
word: string,
handler: (ctx: BotModuleContext, message: ChatMessage) => Promise
): KeywordDefinition;
function pattern(
regex: string,
handler: (ctx: BotModuleContext, message: ChatMessage, matched: string) => Promise
): PatternDefinition;
function event(
eventType: string,
handler: (ctx: BotModuleContext, evt: Record) => Promise
): EventDefinition;
function timer(
name: string,
handler: (ctx: BotModuleContext) => Promise
): TimerDefinition;
function moderate(
handler: (ctx: BotModuleContext, message: ChatMessage) => Promise
): ModerateDefinition;
```
### BotModuleContext
See [Bot Module Context](/sdk/hooks/bot-module-context) for the full API reference.
### HandlerResponse
```typescript
interface HandlerResponse {
reply?: string;
actions?: ActionRequest[];
}
interface ActionRequest {
action: string;
params: Record;
}
```
### ModerationResponse
```typescript
interface ModerationResponse {
block: boolean;
action?: "delete" | "timeout" | "ban";
duration?: number;
reason?: string;
}
```
### ChatMessage
```typescript
interface ChatMessage {
text: string;
platformMessageId: string;
}
```
---
## API Reference / CLI Reference
# CLI Reference
Quick-reference table and flag reference for the `lumio` CLI.
## Installation
```bash
npm install -g @zaflun/lumio-cli
# or
pnpm add -g @zaflun/lumio-cli
```
## Commands
| Command | Description |
|---------|-------------|
| `lumio login` | Authenticate to your developer account |
| `lumio logout` | Remove stored credentials |
| `lumio init` | Scaffold a new extension |
| `lumio dev` | Start local development server |
| `lumio build` | Build for production |
| `lumio deploy` | Deploy a new version |
| `lumio status` | Show extension status |
| `lumio logs` | Stream server function logs |
| `lumio secrets set ` | Store a secret |
| `lumio secrets delete ` | Delete a secret |
| `lumio secrets list` | List secret key names |
---
## lumio login
```
lumio login [--token ]
```
Without `--token`, opens a browser-based OAuth flow. With `--token`, uses the provided API key directly (useful in CI).
| Flag | Description |
|------|-------------|
| `--token ` | Authenticate with an API key instead of browser flow |
---
## lumio init
```
lumio init [name] [--template ]
```
| Flag | Description |
|------|-------------|
| `--template ` | Scaffold from a template: `blank`, `alert`, `scoreboard`, `poll`, `chat` |
| `--dir ` | Directory to create the project in (default: `./`) |
---
## lumio dev
```
lumio dev [--port ] [--no-open]
```
Starts a local development server with hot module replacement. Opens the preview URL in the browser automatically.
| Flag | Description |
|------|-------------|
| `--port ` | HTTP port for the dev server (default: `3010`) |
| `--no-open` | Do not open the browser automatically |
| `--mock ` | Load a mock events file for simulating stream events |
The dev server injects a mock Lumio runtime so `useLumioEvent`, `useLumioConfig`, and `useLumioAction` all work without a real connected overlay.
---
## lumio build
```
lumio build [--outdir ]
```
Compiles and bundles all surfaces for production. Output goes to `dist/` by default.
| Flag | Description |
|------|-------------|
| `--outdir ` | Output directory (default: `dist`) |
| `--analyze` | Print bundle size breakdown after build |
Build fails if:
- A surface bundle exceeds 5 MB (gzipped)
- TypeScript type errors are present
- `lumio.config.json` fails schema validation
---
## lumio deploy
```
lumio deploy [--version ] [--changelog ] [--dry-run]
```
Builds the extension and submits it as a new version for review. Equivalent to `lumio build` followed by `POST /extensions/:id/versions`.
| Flag | Description |
|------|-------------|
| `--version ` | Override version from `lumio.config.json` |
| `--changelog ` | Short changelog for this version |
| `--dry-run` | Build and validate but do not upload |
| `--no-build` | Skip the build step (use existing `dist/`) |
After deploying, the version enters `pending` status and awaits review.
---
## lumio status
```
lumio status [extension-id]
```
Prints the current extension status, latest version, install count, and pending review details.
| Flag | Description |
|------|-------------|
| `--json` | Output as JSON |
---
## lumio logs
```
lumio logs [--tail] [--since ] [--level ]
```
Streams server function logs to stdout. Requires the extension to have server functions enabled.
| Flag | Description |
|------|-------------|
| `--tail` | Follow new logs in real time (default: true) |
| `--no-tail` | Print recent logs and exit |
| `--since ` | Show logs from the past duration, e.g. `1h`, `30m`, `7d` |
| `--level ` | Filter by log level: `debug`, `info`, `warn`, `error` |
| `--json` | Raw JSON log output |
---
## lumio secrets
```
lumio secrets set
lumio secrets delete
lumio secrets list
```
Manages secrets stored in Lumio Vault for your extension. Secrets are available in server functions via `ctx.secrets.get()`.
| Subcommand | Description |
|-----------|-------------|
| `set ` | Create or update a secret |
| `delete ` | Delete a secret |
| `list` | List key names (values are never shown) |
Secret keys must match `[A-Z][A-Z0-9_]*` (uppercase, starting with a letter).
---
## Environment variables
| Variable | Description |
|----------|-------------|
| `LUMIO_API_KEY` | API key — overrides stored credentials |
| `LUMIO_EXTENSION_ID` | Extension ID — overrides `lumio.config.json` |
| `LUMIO_API_URL` | Override the API base URL (for self-hosted / staging) |
| `NO_COLOR` | Disable colored output |
---
## Exit codes
| Code | Meaning |
|------|---------|
| `0` | Success |
| `1` | General error (see stderr) |
| `2` | Authentication error |
| `3` | Validation error (config or bundle) |
| `4` | Network error / API unreachable |
| `5` | Rate limit exceeded |
---
## API Reference / TypeScript Types
# TypeScript Types
Complete TypeScript type reference for the Lumio Developer SDK and Developer REST API.
## Core extension types
### Extension
Represents a published extension in the Lumio store.
```typescript
interface Extension {
id: string; // "ext_01j9k2m3n4p5q6r7s8t9u0v1w2"
slug: string; // "my-scoreboard"
name: string;
description: string;
shortDescription: string;
authorId: string;
visibility: "private" | "unlisted" | "public";
status: "draft" | "in_review" | "approved" | "rejected" | "suspended";
pricing: PricingConfig;
currentVersionId: string | null;
installCount: number;
rating: number | null;
ratingCount: number;
createdAt: string; // ISO 8601
updatedAt: string;
}
```
### ExtensionVersion
A specific published version of an extension.
```typescript
interface ExtensionVersion {
id: string;
extensionId: string;
version: string; // semver, e.g. "1.2.0"
changelog: string;
status: "pending" | "approved" | "rejected";
targets: ExtensionTarget[];
hasServer: boolean;
bundleSizeBytes: number;
submittedAt: string;
approvedAt: string | null;
}
type ExtensionTarget = "layer" | "editor" | "interactive";
```
### ExtensionInstall
A record of an extension installed in a user's Lumio account.
```typescript
interface ExtensionInstall {
id: string; // installId
extensionId: string;
extensionVersionId: string;
accountId: string;
overlayId: string | null; // null if not yet placed on an overlay
config: Record;
installedAt: string;
updatedAt: string;
}
```
### PricingConfig
```typescript
type PricingConfig =
| { type: "free" }
| { type: "one_time"; amount: number; currency: "EUR" | "USD" | "GBP" }
| {
type: "subscription";
amount: number;
currency: "EUR" | "USD" | "GBP";
interval: "monthly";
};
```
## Config types
### LumioConfig
The shape returned by `useLumioConfig()`. The `config` field is the raw JSON stored by the editor.
```typescript
interface UseLumioConfigResult> {
config: T | null;
setConfig: (patch: Partial) => Promise;
loading: boolean;
}
```
### LumioConfigSchema
A field descriptor used in the declarative `defineConfig()` API.
```typescript
type LumioConfigField =
| { type: "string"; label: string; default?: string; placeholder?: string }
| { type: "number"; label: string; default?: number; min?: number; max?: number }
| { type: "boolean"; label: string; default?: boolean }
| { type: "select"; label: string; options: Array<{ value: string; label: string }>; default?: string }
| { type: "color"; label: string; default?: string };
```
## Event types
### LumioEvent
```typescript
interface LumioEvent {
type: LumioEventType;
timestamp: string; // ISO 8601
data: T;
}
type LumioEventType =
// Twitch
| "twitch:follower"
| "twitch:subscribe"
| "twitch:gift_sub"
| "twitch:resub"
| "twitch:cheer"
| "twitch:raid"
| "twitch:reward"
| "twitch:hype_train"
| "twitch:hype_train_end"
| "twitch:poll"
| "twitch:poll_end"
| "twitch:prediction"
| "twitch:prediction_lock"
| "twitch:prediction_end"
| "twitch:goal"
| "twitch:goal_end"
| "twitch:ad_break"
| "twitch:ban"
| "twitch:unban"
| "twitch:stream_online"
| "twitch:stream_offline"
| "twitch:stream_update"
// YouTube
| "youtube:subscribe"
| "youtube:member"
| "youtube:superchat"
| "youtube:supersticker"
| "youtube:gift_membership"
| "youtube:gift_membership_received"
| "youtube:poll"
// Kick
| "kick:follower"
| "kick:subscribe"
| "kick:gift"
| "kick:stream_online"
| "kick:stream_offline"
| "kick:chat"
// Trovo
| "trovo:subscribe"
| "trovo:spell"
| "trovo:chat"
// Spotify
| "spotify:track"
| "spotify:play"
| "spotify:pause"
| "spotify:skip"
| "spotify:volume"
| "spotify:queue_add"
| "spotify:device"
| "spotify:playlist_add"
| "spotify:playlist_remove"
| "spotify:playlist_create"
| "spotify:playlist_edit"
| "spotify:playlist_delete"
// StreamElements
| "streamelements:tip"
// Chat — per-platform
| "twitch:chat"
| "youtube:chat"
// Chat — catch-all (fires for all platforms)
| "chat:message";
```
### Event payload types
```typescript
interface TwitchFollowerData {
userName: string;
userId: string;
followedAt: string;
}
interface TwitchSubscribeData {
userName: string;
userId: string;
tier: "1000" | "2000" | "3000" | "prime";
months: number;
message: string | null;
}
interface TwitchGiftSubData {
gifterName: string;
gifterUserId: string;
recipientName: string;
recipientUserId: string;
tier: "1000" | "2000" | "3000";
}
interface TwitchResubData {
userName: string;
userId: string;
tier: "1000" | "2000" | "3000" | "prime";
months: number;
message: string | null;
}
interface TwitchCheerData {
userName: string;
userId: string;
bits: number;
message: string;
}
interface TwitchRaidData {
fromBroadcasterName: string;
fromBroadcasterId: string;
viewerCount: number;
}
interface TwitchRewardData {
userName: string;
userId: string;
rewardId: string;
rewardTitle: string;
rewardCost: number;
input: string | null;
status: "unfulfilled" | "fulfilled" | "canceled";
}
interface TwitchBanData {
userName: string;
userId: string;
reason: string | null;
moderatorName: string;
}
interface TwitchUnbanData {
userName: string;
userId: string;
moderatorName: string;
}
interface TwitchStreamOnlineData {
broadcasterName: string;
broadcasterId: string;
startedAt: string;
}
interface TwitchStreamOfflineData {
broadcasterName: string;
broadcasterId: string;
}
interface TwitchStreamUpdateData {
title: string;
categoryName: string;
categoryId: string;
}
interface SpotifyTrackData {
title: string;
artist: string;
album: string;
albumArtUrl: string;
duration: number; // milliseconds
progress: number; // milliseconds
isPlaying: boolean;
}
interface ChatMessageData {
platform: "twitch" | "youtube" | "kick" | "trovo";
userName: string;
userId: string;
message: string;
emotes: Array<{ id: string; name: string; url: string }>;
badges: string[];
color?: string;
}
```
## Server function types
### ActionHandler
```typescript
type ActionHandler = (
ctx: ActionContext,
args: TArgs
) => Promise;
interface ActionContext {
db: StorageContext;
fetch: typeof fetch;
secrets: SecretsContext;
realtime: RealtimeContext;
identity: IdentityContext | null;
}
```
### StorageContext
```typescript
interface StorageContext {
get(scope: "global" | "install" | "user", key: string): Promise;
get(scope: "global" | "install" | "user", ...keys: string[]): Promise;
set(scope: "global" | "install" | "user", key: string, value: unknown): Promise;
set(scope: "global" | "install" | "user", ...keysAndValue: [...string[], unknown]): Promise;
delete(scope: "global" | "install" | "user", ...keys: string[]): Promise;
list(scope: "global" | "install" | "user", prefix?: string): Promise;
}
```
### SecretsContext
```typescript
interface SecretsContext {
get(key: string): string; // throws if key not set
has(key: string): boolean;
}
```
### RealtimeContext
```typescript
interface RealtimeContext {
push(event: string, data: unknown): Promise;
}
```
## Theme types
```typescript
interface LumioTheme {
mode: "light" | "dark";
primaryColor: string; // hex, e.g. "#6366f1"
fontFamily: string;
borderRadius: number; // px
}
```
## Identity types
```typescript
interface LumioIdentity {
userId: string;
userName: string;
platform: "twitch" | "youtube" | "kick" | "trovo" | null;
platformUserId: string | null;
isAuthenticated: boolean;
}
```
## Validation schema types
```typescript
import { v } from "@zaflun/lumio-sdk/server";
// Primitive validators
v.string() // string
v.number() // number
v.boolean() // boolean
v.null_() // null
// Compound validators
v.optional(v.string()) // string | undefined
v.nullable(v.string()) // string | null
v.array(v.string()) // string[]
v.object({ key: v.string() }) // { key: string }
v.union([v.string(), v.number()]) // string | number
v.literal("exact") // "exact"
```
---
## Guides / Debugging
# Debugging
This guide covers common debugging techniques for Lumio extension development, from local dev server issues to production server function errors.
## Extension Developer Mode
Enable **Extension Developer Mode** in the widget or overlay editor via **Settings → Extension Developer Mode**. When active:
- Extension bundle serving loads the **latest draft or testing version** instead of the published version for extensions you develop
- A **Debug Panel** (floating window) opens automatically with tabs for Events, Storage, Messages, and Console output
- A toast notification confirms activation: *"Developer mode enabled — extensions will use dev versions"*
This lets you test draft versions directly in the editor without publishing. Toggle it off to return to the published version.
The setting is stored on your user account (`extension_dev_mode`) and persists across sessions. It only affects extensions where you are a team member/developer — other extensions always load the published version.
## Local development
### Dev server logs
When `lumio dev` is running, logs from server functions are printed directly to the terminal. Look for lines prefixed with `[server]`:
```
[server] INFO getScoreboard called with { sport: "basketball", league: "nba" }
[server] ERROR getScoreboard failed: ESPN API error: 429
```
Add `console.log()` statements in your handler freely during development — they appear in the terminal and are stripped from the production build.
### Inspecting the overlay preview
The dev server opens a preview at `http://localhost:3010/preview`. This page renders the `layer` surface inside a 1920×1080 canvas. Open your browser's DevTools (F12) to:
- Inspect the React component tree
- Check the console for SDK errors
- Profile render performance
The editor panel is at `http://localhost:3010/editor` and the interactive page at `http://localhost:3010/interactive`.
### Simulating events
Use `--mock` to load a mock events file:
```bash
lumio dev --mock ./mocks/events.json
```
The events file is an array of event objects:
```json
[
{
"type": "twitch:follower",
"timestamp": "2026-05-01T10:00:00Z",
"data": { "userName": "testuser", "userId": "u_001", "followedAt": "2026-05-01T10:00:00Z" }
},
{
"type": "spotify:track",
"timestamp": "2026-05-01T10:01:00Z",
"data": {
"title": "Test Track",
"artist": "Test Artist",
"album": "Test Album",
"albumArtUrl": "https://i.scdn.co/image/placeholder",
"duration": 210000,
"progress": 45000,
"isPlaying": true
}
}
]
```
In the preview UI, click **Send Event** to dispatch the next event from the file.
---
## Debugging server functions
### Checking logs in production
Stream production logs with:
```bash
lumio logs --tail
```
Filter by error level:
```bash
lumio logs --level error --since 1h
```
### Common server function errors
**`"Host not in allowHosts"`**
You called `ctx.fetch()` with a host not listed in `egress.allowHosts`. Add the host to `lumio.config.json`:
```json
{
"egress": {
"allowHosts": ["api.example.com"]
}
}
```
**`"Secret key not found: MY_KEY"`**
The secret hasn't been set for this extension. Run:
```bash
lumio secrets set MY_KEY "your_value_here"
```
**`"Action invocation timeout"`**
The handler took longer than 30 seconds. Check for:
- Slow external API calls — add a timeout to `ctx.fetch()`
- Unbounded loops in the handler
- Large storage reads/writes
```typescript
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8_000);
const res = await ctx.fetch("https://slow-api.example.com/data", {
signal: controller.signal,
});
clearTimeout(timeout);
```
**`"Storage key size exceeded"`**
The value you tried to store is larger than 64 KB. Compress or split the data across multiple keys.
---
## React component debugging
### Unexpected re-renders
If your layer re-renders too frequently, check:
1. **`useLumioEvent` in a dependency array** — the event reference changes on every new event. If your `useEffect` depends on the event, it runs on every event. That is the intended behavior.
2. **Object/array literals in `style` props** — create style objects outside the component or use `useMemo`:
```tsx
// Bad — new object on every render
// Good — stable reference
const containerStyle: React.CSSProperties = { color: "red", padding: 8 };
```
3. **`setConfig` in a loop** — `setConfig` triggers a re-render and a network call. Batch updates:
```tsx
// Bad
await setConfig({ a: 1 });
await setConfig({ b: 2 });
// Good
await setConfig({ a: 1, b: 2 });
```
### White screen / nothing renders
1. Check the browser console for JavaScript errors
2. Make sure `Lumio.render()` is called at the module level, not inside a React component
3. Verify the `target` in `Lumio.render()` matches the URL you're previewing (`layer`, `editor`, or `interactive`)
---
## Build errors
### TypeScript errors
Run the TypeScript compiler to see all type errors at once:
```bash
cd my-extension && npx tsc --noEmit
```
### Bundle too large
Run `lumio build --analyze` to see which modules contribute to the bundle size. Common culprits:
- Importing entire icon libraries — import individual icons instead
- Importing large utility libraries — check if a smaller alternative exists
- Embedding image data as base64 — use external URLs or the static assets folder instead
### MDX / JSX syntax error in docs
Not applicable to extensions, but if you're editing `apps/developer-docs/docs/`, remember that `{placeholder}` in MDX headings must be escaped as `{placeholder}` outside code fences.
---
## Guides / Testing Extensions
# Testing Extensions
Lumio extensions are React applications with server functions. This guide covers unit testing components, testing server functions, and manual testing against a real overlay before submitting for review.
## Testing strategy
| Level | What to test | Tool |
|-------|-------------|------|
| Unit | Pure functions, validators, data transforms | Vitest or Jest |
| Component | Rendering logic, UI state, config reads | React Testing Library |
| Integration | Server function handlers with mocked storage | Vitest + hand-rolled mocks |
| Manual (local) | Full flow: editor → layer → events | `lumio dev` |
| Manual (staging) | Live overlay in browser source with real events | `lumio deploy` to staging |
---
## Setting up Vitest
```bash
pnpm add -D vitest @testing-library/react @testing-library/user-event jsdom
```
`vitest.config.ts`:
```typescript
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
globals: true,
},
});
```
`package.json`:
```json
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
}
}
```
---
## Testing server function handlers
Server functions are plain async functions — test the `handler` directly by constructing a minimal `ctx` mock.
```typescript
// server/functions.test.ts
import { describe, it, expect, vi } from "vitest";
import { incrementScore } from "./functions";
function makeCtx(storage: Record = {}) {
return {
db: {
get: vi.fn(async (_scope, ...keys) => storage[keys.join("/")] ?? null),
set: vi.fn(async (_scope, ...keysAndValue) => {
const value = keysAndValue[keysAndValue.length - 1];
const keys = (keysAndValue.slice(0, -1) as string[]).join("/");
storage[keys] = value;
}),
delete: vi.fn(),
list: vi.fn(async () => []),
},
fetch: vi.fn(),
secrets: { get: vi.fn((k: string) => `mock_${k}`), has: vi.fn(() => true) },
realtime: { push: vi.fn() },
identity: null,
};
}
describe("incrementScore", () => {
it("starts at 0 and increments", async () => {
const ctx = makeCtx();
const result = await incrementScore.handler(ctx, { amount: 5 });
expect(result.score).toBe(5);
});
it("adds to existing score", async () => {
const ctx = makeCtx({ score: 10 });
const result = await incrementScore.handler(ctx, { amount: 3 });
expect(result.score).toBe(13);
});
it("pushes a realtime event", async () => {
const ctx = makeCtx();
await incrementScore.handler(ctx, { amount: 1 });
expect(ctx.realtime.push).toHaveBeenCalledWith("score:updated", { score: 1 });
});
});
```
---
## Testing React components
Use React Testing Library to render surfaces and assert on output.
```typescript
// src/layer.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { ScoreDisplay } from "./layer";
// Mock the SDK hooks
vi.mock("@zaflun/lumio-sdk", () => ({
useLumioConfig: () => ({
config: { homeTeam: "LAL", awayTeam: "BOS" },
setConfig: vi.fn(),
loading: false,
}),
useLumioTheme: () => ({ mode: "dark", primaryColor: "#6366f1", fontFamily: "sans-serif", borderRadius: 8 }),
useLumioEvent: () => null,
Box: ({ children, ...props }: React.HTMLAttributes) => {children}
,
Text: ({ content }: { content: string }) => {content},
}));
describe("ScoreDisplay", () => {
it("renders team names from config", () => {
render();
expect(screen.getByText("LAL")).toBeInTheDocument();
expect(screen.getByText("BOS")).toBeInTheDocument();
});
});
```
---
## Manual testing checklist
Before submitting for review, work through this checklist using `lumio dev`:
### Editor panel
- [ ] All config fields render correctly
- [ ] Changing a setting immediately updates the layer (no page reload needed)
- [ ] Default values display when config is empty
- [ ] Text inputs accept long strings without layout breakage
- [ ] Required fields show a validation hint if empty
### Layer
- [ ] Renders correctly at 1920×1080
- [ ] Renders correctly at 1280×720 (test by resizing the preview browser window)
- [ ] Transparent backgrounds are truly transparent (check against a non-black background)
- [ ] Fonts load correctly (no fallback to system font)
- [ ] Animations play at smooth 60 fps
### Event handling
- [ ] Displays correctly when no event has arrived yet (null state)
- [ ] Responds to the first event within 1 second
- [ ] Handles rapid events without visual glitches or memory leaks
- [ ] Auto-dismiss timers work correctly
### Interactive page
- [ ] Renders on mobile screen sizes (375px wide)
- [ ] Unauthenticated users see an appropriate message or login prompt
- [ ] All user interactions are reflected on the layer within 500ms
### Server functions
- [ ] Actions return expected results
- [ ] Error conditions are handled gracefully (no unhandled promise rejections in the console)
- [ ] External API failures show a user-friendly fallback
---
## Staging deployment
Deploy to staging to test with real Twitch/YouTube events before submitting:
```bash
lumio deploy --version 0.9.0-rc1 --changelog "Release candidate for testing"
```
Install the extension on a test overlay and go live on Twitch. Trigger real events (follow, subscribe, chat) to verify end-to-end behavior.
Check production logs while testing:
```bash
lumio logs --tail --level info
```
---
## Guides / Migration Guide
# Migration Guide
This page documents breaking changes between SDK major versions and the steps to migrate existing extensions.
## Current version
The current SDK major version is **v1**. This is the initial stable release. No migrations are required for extensions created with `@zaflun/lumio-sdk@^1.0.0`.
## Future migrations
When a new major version is released, this page will be updated with:
- The full list of breaking changes
- Automated codemod scripts where available
- Step-by-step manual migration instructions
- A migration deadline (how long the previous version will continue to receive bug fixes)
## Staying current
Watch the [`@zaflun/lumio-sdk` release history on npm](https://www.npmjs.com/package/@zaflun/lumio-sdk?activeTab=versions) to be notified when new versions are published.
The `lumio status` CLI command also shows whether your extension's SDK version is current:
```bash
lumio status
```
```
Extension: Sports Scoreboard (ext_01j9k2m3n4p5q6r7)
Status: approved
Version: 1.2.0
SDK: @zaflun/lumio-sdk@1.0.0 ✓ (latest)
```
## Deprecation policy
Before any breaking change is shipped:
1. The deprecated API is marked in the TypeScript types with a `@deprecated` JSDoc tag
2. The runtime emits a `console.warn` when the deprecated API is used in dev mode
3. A migration guide entry is published at least 3 months before removal
4. The CLI prints a warning during `lumio build` if deprecated APIs are detected
## Reporting compatibility issues
If you find that an SDK update broke your extension in an undocumented way, file a bug report in the developer portal with:
- Your SDK version (`package.json` → `@zaflun/lumio-sdk`)
- The previous SDK version where it worked
- A minimal reproduction case
We treat unannounced breaking changes as bugs and aim to fix them within 48 hours.
---
## Guides / Best Practices
# Best Practices
Guidelines for building reliable, performant, and review-friendly Lumio extensions.
## Performance
### Keep bundle sizes small
Browser Sources have limited memory. Aim for bundles under 1 MB gzipped.
- Import only what you use: `import { Box, Text } from "@zaflun/lumio-sdk"` not `import * as SDK from "@zaflun/lumio-sdk"`
- Avoid lodash, moment.js, and other large utility libraries — use native browser APIs
- Prefer `` over inline base64 data URIs for assets larger than 1 KB
- Run `lumio build --analyze` to identify large dependencies
### Minimize re-renders on the layer
The layer runs in an Browser Source at 60 fps. Expensive renders cause dropped frames.
- Memoize computed values with `useMemo`
- Memoize callbacks with `useCallback` when passing them to child components
- Keep component state as local as possible — avoid a single top-level state object for everything
- Use `React.memo` on pure display components that receive stable props
### Limit event-driven updates
`useLumioEvent` triggers a re-render on every event. If you only need the data after an event (not reactive to each one), debounce or queue events in a ref:
```tsx
const lastEventRef = useRef(null);
const event = useLumioEvent("twitch:cheer");
useEffect(() => {
if (event === lastEventRef.current) return; // Already processed
lastEventRef.current = event;
// Handle event...
}, [event]);
```
---
## Reliability
### Handle the null state
`useLumioEvent` returns `null` before the first event. `useLumioConfig` returns `null` while loading. Always handle these:
```tsx
const event = useLumioEvent("twitch:follower");
const { config, loading } = useLumioConfig();
if (loading) return null; // or a skeleton
if (!config) return null; // not configured yet
if (!event) return ; // waiting for first event
```
### Never store secrets in client code
Secrets (API keys, webhook secrets) must live in `lumio secrets`, not in the extension bundle or `lumio.config.json`. A user can inspect the bundle at any time.
### Validate server function inputs
Even though `v` validators strip unknown fields, add semantic validation in your handler:
```typescript
handler: async (ctx, args) => {
if (args.amount <= 0 || args.amount > 1000) {
throw new Error("Amount must be between 1 and 1000");
}
// ...
}
```
### Handle external API failures gracefully
External APIs can be slow or unavailable. Always have a fallback:
```typescript
handler: async (ctx, args) => {
try {
const res = await ctx.fetch("https://api.example.com/data");
return await res.json();
} catch {
// Return cached data if available
return await ctx.db.get("global", "last_known_data") ?? { empty: true };
}
}
```
---
## User experience
### Design for 1920×1080 first
Most streamers run at 1080p. Test at 1080p first, then check at 720p. Avoid fixed pixel positions that break at different canvas sizes — use percentage or anchor-based positioning instead.
### Respect the overlay canvas
Overlays sit on top of game footage or IRL video. Keep the overlay visually lightweight:
- Prefer semi-transparent backgrounds over solid fills
- Avoid covering more than 20% of the canvas area
- Ensure text is readable against both light and dark backgrounds
### Support the streamer's theme
Use `useLumioTheme()` to adapt colors to the streamer's chosen theme instead of hardcoding colors. At minimum, use the `mode` field to choose light/dark variants:
```tsx
const theme = useLumioTheme();
const bg = theme.mode === "dark" ? "rgba(0,0,0,0.7)" : "rgba(255,255,255,0.8)";
```
### Provide sensible defaults
Every config field should have a default so the extension works out of the box before the streamer visits the editor:
```tsx
const { config } = useLumioConfig();
const position = config?.position ?? "top-right";
const fontSize = config?.fontSize ?? 14;
```
---
## Review readiness
These practices help your extension pass review faster.
### Match egress to purpose
Only list hosts in `allowHosts` that your extension actually calls, and ensure each one is clearly related to the extension's stated purpose. Reviewers reject extensions with overly broad or unexplained egress hosts.
### Write a clear store listing
The review team reads your store description. Explain what the extension does, what permissions it needs and why, and what data it stores. Vague descriptions cause review delays.
### Test before submitting
Run through the [testing checklist](/guides/testing#manual-testing-checklist) before every submission. Extensions that crash or display a blank screen in testing are rejected without deeper review.
### Use semantic versioning
Follow semver for version strings: `MAJOR.MINOR.PATCH`. Increment `MAJOR` for breaking config changes that require re-configuring the editor panel. Increment `MINOR` for new features. Increment `PATCH` for bug fixes.
### Write meaningful changelogs
The changelog is shown to users when they update the extension. Write what changed from the user's perspective, not internal implementation notes:
```
# Good
Fixed score display not updating during overtime periods.
Added support for displaying 3-point shooting percentage.
# Bad
Refactored ESPN fetch handler. Fixed off-by-one in state reducer.
```
---
## Guides / Sandbox Architecture
# Sandbox Architecture
Lumio runs every extension inside a multi-layer sandbox. Understanding the sandbox helps you reason about what extensions can and cannot do, and makes debugging straightforward when something unexpected happens.
## Why sandboxing matters
Extensions are third-party code executed inside the viewer's browser and Lumio's servers. Without isolation, a malicious or buggy extension could:
- Read or write another extension's storage
- Send arbitrary network requests using the viewer's session
- Crash or freeze the host overlay page
- Access sensitive viewer data (tokens, cookies, clipboard)
The Lumio sandbox prevents all of the above at the architecture level rather than relying on code review alone.
## 3-Layer architecture
```
Host (lumio.vision)
└─ Supervisor iframe (supervisor.ext.lumio.vision)
└─ Extension Runtime iframe (ext.lumio.vision)
└─ Web Worker (lumio-runtime.js + your extension bundle)
```
Each layer runs on a **distinct origin** enforced by the browser's same-origin policy. `postMessage` is the only communication channel between layers; there is no shared memory, no shared DOM, and no shared storage.
### Layer 1 — Host (`lumio.vision`)
The host page is the Lumio overlay, widget, or dashboard editor. It owns the real DOM and renders what viewers see. The host communicates exclusively with the Supervisor iframe and never touches extension code directly.
The host creates the Supervisor iframe via `ExtensionWorkerManager` and sends messages to `supervisorIframe.contentWindow.postMessage(...)`.
### Layer 2 — Supervisor (`supervisor.ext.lumio.vision`)
The Supervisor is a minimal (~90-line) vanilla JavaScript page hosted at a separate subdomain. It acts as a **message firewall**:
- Messages arriving from the Extension Runtime (Worker → Host direction) are validated against an allowlist of permitted JSON-RPC methods before being forwarded to the Host.
- Messages arriving from the Host (Host → Worker direction) are validated against a second allowlist before being forwarded to the Extension Runtime.
- Any message with an unknown or disallowed `method` is dropped with a console warning. It is never forwarded in either direction.
JSON-RPC response objects (those with an `id` field but no `method`) are forwarded without method validation, as they are replies to explicit requests.
**Permitted Worker → Host methods:**
`ui.render`, `ui.ready`, `ui.keyframes`, `ui.error`, `storage.set`, `storage.get`, `server.query`, `server.mutation`, `action.execute`
**Permitted Host → Worker methods:**
`lifecycle.init`, `lifecycle.destroy`, `event.callback`, `event.platform`, `storage.update`, `theme.change`
### Layer 3 — Extension Runtime (`ext.lumio.vision`)
The Extension Runtime is another minimal vanilla JavaScript page hosted at a separate subdomain. It:
1. Blocks `localStorage` and `sessionStorage` entirely via `Object.defineProperty` — throwing a `DOMException` on access.
2. Creates the Web Worker (`new Worker(runtimeUrl)`) where your actual extension code runs.
3. Forwards all messages from the Worker to the Supervisor (parent) and vice versa, without additional validation (validation is done at the Supervisor layer).
The storage blocking prevents cross-extension data leakage — all extensions share the `ext.lumio.vision` origin, so browser storage would be shared without this guard. The `useExtensionStorage` SDK hook uses Lumio's Redis-backed storage instead, which is properly scoped per extension.
### Web Worker
Your extension bundle and the Lumio runtime (`lumio-runtime.js`) run inside a Web Worker. This means:
- No DOM access at all — `document` and `window` do not exist in this context.
- React renders through a custom reconciler that serialises the component tree to `LumioComponentNode[]` and sends it to the Host via `ui.render` — the Host renders the actual DOM elements.
- Network requests, storage reads/writes, and server function calls all go through the SDK which routes them via `postMessage` through the Supervisor.
## What extensions can do
| Capability | How |
|------------|-----|
| Render UI components | `Box`, `Text`, `Button`, `Image`, etc. via `@zaflun/lumio-sdk` |
| Read and write extension storage | `useExtensionStorage()` hook |
| Call server functions | `useQuery()`, `useMutation()` hooks |
| Receive platform events | `useLumioEvent()` hook |
| Trigger custom actions | `useLumioAction()` hook |
| Respond to theme changes | `useLumioTheme()` hook |
| Animate UI elements | `defineKeyframes()`, `useAnimation()` |
## What extensions cannot do
| Action | Reason |
|--------|--------|
| Access the DOM directly | Web Worker has no DOM context |
| Use `localStorage` / `sessionStorage` | Blocked by the Extension Runtime |
| Make arbitrary network requests | Only `ctx.fetch()` with declared egress hosts (server functions) |
| Access viewer cookies or JWT tokens | Different origin; cookies are not sent to `ext.lumio.vision` |
| Navigate the top-level page | No access to `window.top` or `window.parent.location` |
| Send undeclared JSON-RPC methods | Supervisor drops unknown methods |
| Access another extension's data | Storage is scoped per extension; no shared globals |
## How this affects development
The sandbox is **transparent** to extension developers. The `lumio dev` CLI sets up local equivalents of all three layers automatically:
- A local Supervisor page at `http://localhost:3200`
- A local Extension Runtime page at `http://localhost:3201`
- Your extension bundle via the Vite dev server at `http://localhost:5173`
You write the same `@zaflun/lumio-sdk` components and hooks as always. The SDK handles all cross-layer communication internally.
## Debugging tips
### Viewing the iframe tree
Open Chrome DevTools → Elements panel. You will see:
```
body
└─ iframe (src: supervisor.ext.lumio.vision or localhost:3200)
└─ iframe (src: ext.lumio.vision or localhost:3201)
```
Each iframe has its own DevTools context. Use the context switcher (top-left of the Console panel) to select the Supervisor or Runtime iframe.
### Supervisor console messages
The Supervisor logs a warning when it drops a message:
```
[Supervisor] Blocked worker→host message: some.unknown.method
[Supervisor] Blocked host→worker message: some.unknown.method
```
If you see these messages, a method your extension is trying to use is not in the allowlist. This means either:
- You are calling an internal method that is not part of the SDK's public protocol.
- A version mismatch exists between your extension bundle and the installed runtime.
### Worker errors
Uncaught errors in the Web Worker surface as `ui.error` messages on the Host. You can also inspect them by selecting the Worker context in DevTools → Sources → Threads.
### iframe not loading
If the Supervisor or Extension Runtime iframe fails to load:
- Verify the `NEXT_PUBLIC_SUPERVISOR_URL` and `NEXT_PUBLIC_EXTENSION_RUNTIME_URL` env vars point to reachable origins.
- Check that `CORS` headers allow the host origin. The static apps are served by Cloudflare Pages which sends permissive CORS headers by default.
- In local dev, ensure `just dev-supervisor` (port 3200) and `just dev-runtime` (port 3201) are running.
---
## Guides / AI Assistant
# AI Assistant Integration
Two ways to give your AI coding assistant full Lumio SDK context:
| Approach | Setup | Best for |
|----------|-------|----------|
| **llms.txt** | Zero install — paste a URL | Quick start, one-off questions, any AI tool |
| **Agent Skill** | `npx @zaflun/lumio-agent-skill add` | Persistent per-project context, deep integration |
Both approaches are complementary — use llms.txt for immediate context and the agent skill for ongoing project work.
## llms.txt — Zero-Install Documentation
The Lumio developer docs are available as machine-readable files that any AI assistant can consume directly:
- **[llms.txt](https://developers.lumio.vision/llms.txt)** — structured index of all documentation pages with descriptions
- **[llms-full.txt](https://developers.lumio.vision/llms-full.txt)** — complete documentation (~400 KB) in a single file
### How to use
Paste the URL or file content into your assistant's context window. Most AI tools accept URLs directly:
```
Read https://developers.lumio.vision/llms-full.txt and help me build a loyalty points bot module.
```
Or fetch and pipe the content:
```bash
curl -s https://developers.lumio.vision/llms-full.txt | pbcopy
```
These files are auto-generated from the documentation source on every build — they always reflect the latest published docs.
### When to use llms.txt vs the agent skill
Use **llms.txt** when you want to give an AI assistant context without modifying your project. Useful for:
- Asking questions about the Lumio SDK before starting a project
- One-off code generation in a chat interface
- Tools that support URL fetching but not local skill files
Use the **agent skill** when you want persistent, automatic context on every conversation in your project. The skill files are loaded automatically by supported tools without any manual URL pasting.
## Agent Skill — Per-Project Context
The `@zaflun/lumio-agent-skill` package installs reference files into your extension project that teach AI coding assistants how to build Lumio extensions correctly without you having to explain the SDK every session.
Supported tools: **Claude Code**, **GitHub Copilot**, **Cursor**, **Google Gemini CLI**, **OpenAI Codex**, **Windsurf**, and any agent that reads `SKILL.md`, `AGENTS.md`, or `GEMINI.md`.
## What it installs
Running the setup command creates a `.lumio/skills/` directory in your project with the following files:
| File | Purpose |
|------|---------|
| `SKILL.md` | Entry point — overview of the Lumio Extension SDK, conventions, and link map to other files |
| `components.md` | All SDK UI components (``, inputs, display elements) with prop signatures |
| `server-functions.md` | Declarative and handler-based server function patterns, storage scoping, external API calls |
| `cli-workflows.md` | `lumio dev`, `lumio build`, `lumio deploy`, `lumio logs` — full CLI reference |
| `auth.md` | Auth context, identity hooks, OAuth scope requirements |
| `runtime-scopes.md` | Surface targeting (`layer`, `editor`, `interactive`), sandbox constraints |
| `best-practices.md` | Performance, state management, extension manifest dos and don'ts |
| `troubleshooting.md` | Common errors and how to fix them |
AI assistants that read these files understand:
- How `config_schema` fields map to rendered UI inputs in the extension editor panel
- Which hooks (`useLumioConfig`, `useLumioEvent`, `useExtensionStorage`) are available in which surfaces
- How to call external APIs from server functions without hitting the sandbox boundary
- The correct way to declare permissions and egress rules in `lumio.config.ts`
## Installation
Install the CLI globally and run the setup command inside your extension project:
```bash
npm install -g @zaflun/lumio-agent-skill
npx lumio-skills add
```
This creates `.lumio/skills/` with all reference files. The directory is safe to commit to your repository — it contains only read-only reference markdown, no secrets or generated code.
### Single-run alternative
If you prefer not to install globally, use `npx` directly:
```bash
npx @zaflun/lumio-agent-skill add
```
## Using with Claude Code
Claude Code reads `.lumio/skills/SKILL.md` automatically when it is present in the working tree. No additional configuration is required — open your extension project directory in Claude Code and the assistant is already primed with Lumio context.
To manually point the assistant to a skill file, reference it in your prompt:
```
Read .lumio/skills/server-functions.md and help me add an external API call to my scoreboard handler.
```
## Using with GitHub Copilot
Copilot picks up the skill files as workspace context when they are open in an editor tab or referenced in a prompt. For consistent context across a session, add the skill directory to your Copilot workspace instructions (`.github/copilot-instructions.md`):
```markdown
See .lumio/skills/SKILL.md for Lumio Extension SDK conventions.
```
## Using with Cursor
Cursor's `@Docs` and `@File` context commands work with the skill files directly:
```
@File .lumio/skills/components.md What inputs are available for a config_schema field of type "select"?
```
You can also add `.lumio/skills/` to your Cursor project rules so Cursor loads the SKILL.md on every new conversation.
## Using with Google Gemini CLI
Gemini CLI reads `GEMINI.md` in the project root. Create one that references the skill files:
```markdown
## Lumio Extension SDK
This project is a Lumio extension. Read `.lumio/skills/SKILL.md` for the full SDK reference including components, hooks, server functions, and CLI commands.
For detailed references see:
- `.lumio/skills/components.md` — UI components
- `.lumio/skills/server-functions.md` — Server function patterns
- `.lumio/skills/cli-workflows.md` — CLI commands
```
Gemini loads this file automatically at session start.
## Using with OpenAI Codex / ChatGPT
Codex reads `AGENTS.md` in the project root. Create one:
```markdown
## Lumio Extension SDK
This project is a Lumio extension built with `@zaflun/lumio-sdk`.
Read `.lumio/skills/SKILL.md` for the complete SDK reference.
```
This also works with any agent that follows the `AGENTS.md` convention.
## Using with Windsurf
Windsurf reads `.windsurfrules` in the project root. Create one:
```
Read .lumio/skills/SKILL.md for Lumio Extension SDK conventions.
This project uses @zaflun/lumio-sdk for components and hooks, and @zaflun/lumio-cli for development.
```
Windsurf will load this on every new conversation in the project.
## Keeping skills up to date
The skill files are versioned with `@zaflun/lumio-agent-skill`. When the Lumio SDK is updated, re-run the same command to overwrite the old files with the latest reference:
```bash
npx lumio-skills add
```
The command is idempotent — running it multiple times is safe and always writes the version that matches your currently installed `@zaflun/lumio-agent-skill` package.
To update to the latest published version and refresh the skill files in one step:
```bash
npm install -g @zaflun/lumio-agent-skill@latest && npx lumio-skills add
```
## What the assistant will not do
The skill files are reference documentation, not executable code. They teach the assistant Lumio patterns but do not grant it access to your Lumio account, API keys, or extension runtime. The assistant generates code; you review, test with `lumio dev`, and deploy with `lumio deploy`.
---
## Guides / Bot Modules
# Bot Modules
> **Built-in modules:**
> Lumio's built-in bot modules (Link Protection, Spam Protection, Word Filter, Timed Messages) are **system extensions** that run through the same V8 execution model as third-party bot modules. All bot modules -- built-in and custom -- use the same SDK handlers (`defineCommand`, `defineModerate`, etc.).
Bot modules are extensions that handle chat messages, platform events, and timed actions across Twitch, YouTube, Kick, Trovo, and Discord. They run server-side in a V8 isolate — no browser required.
This guide walks through creating a bot module from scratch, writing handlers, testing locally, and deploying.
## Prerequisites
- Node.js 22.13+
- A Lumio developer account
- The `@zaflun/lumio-cli` package installed globally
```bash
npm install -g @zaflun/lumio-cli
lumio login
```
## Create a bot module project
Use `lumio init` and select the `bot_module` category:
```bash
lumio init my-bot-module
```
When prompted:
- **Category:** `bot_module`
- **Platforms:** Select one or more: `twitch`, `youtube`, `kick`, `trovo`, `discord`
- **Server functions:** Yes (auto-enabled for bot modules)
This generates the following project structure:
```
my-bot-module/
├── lumio.config.json
├── server/
│ ├── schema.ts
│ └── functions.ts
├── package.json
└── tsconfig.json
```
Bot modules have no `src/` directory — they are server-only extensions with no browser UI surface.
## Configure the manifest
Open `lumio.config.json` and define your triggers and permissions:
```json
{
"$schema": "https://lumio.vision/schemas/lumio.config.schema.json",
"extension_id": "your-uuid-here",
"name": "My Bot Module",
"category": "bot_module",
"version": "1.0.0",
"platforms": ["twitch", "youtube"],
"server": true,
"permissions": ["chat:send", "chat:read"],
"triggers": {
"commands": [
{
"name": "hello",
"description": "Greet the user",
"cooldown_global": 5,
"cooldown_user": 10,
"min_role": "everyone"
}
],
"keywords": ["gg"],
"events": ["twitch:subscribe"],
"timers": [
{
"name": "reminder",
"interval": 300,
"handler": "periodicReminder"
}
]
},
"config_schema": [
{
"key": "greeting",
"type": "string",
"label": "Greeting message",
"default_value": "Hello",
"required": true
}
]
}
```
Key differences from widget/overlay extensions:
- **`category`** must be `"bot_module"`
- **`platforms`** declares which chat platforms this module supports
- **`triggers`** defines when handlers fire (commands, keywords, patterns, events, timers)
- **`targets`** is not used — bot modules have no visual surfaces
## Define the database schema
If your module needs persistent storage, define tables in `server/schema.ts`:
```typescript
// server/schema.ts
import { defineSchema, defineTable, column } from "@zaflun/lumio-sdk/server";
export default defineSchema({
greetings: defineTable({
user_id: column.string(),
count: column.number(),
last_greeted: column.number(),
}),
});
```
## Write handlers
Bot modules use handler wrappers from `@zaflun/lumio-sdk/server`. Each wrapper corresponds to a trigger type:
```typescript
// server/functions.ts
import { command, keyword, event, timer } from "@zaflun/lumio-sdk/server";
// Command handler — fires when a user types !hello
export const hello = command("hello", async (ctx, args) => {
const greeting = ctx.config.greeting ?? "Hello";
return { reply: `${greeting}, ${ctx.user.name}!` };
});
// Keyword handler — fires when "gg" appears in chat
export const hypeDetector = keyword("gg", async (ctx, message) => {
await ctx.cache.increment("hype_count", 1);
return null; // no reply
});
// Event handler — fires on Twitch subscriptions
export const welcomeSub = event("twitch:subscribe", async (ctx, evt) => {
return {
reply: `Welcome ${evt.user_name}! Thanks for subscribing!`,
};
});
// Timer handler — fires every 300 seconds
export const periodicReminder = timer("reminder", async (ctx) => {
return { reply: "Remember to follow the channel!" };
});
```
Every handler receives a `ctx` object with access to the database, cache, secrets, config, and more. See the [Bot Module Context](/sdk/hooks/bot-module-context) reference for the full API.
## Handler types
| Wrapper | Trigger | Execution | Return |
|---------|---------|-----------|--------|
| `command(name, handler)` | Chat prefix (`!name`) | Sync (500ms deadline) | `{ reply?: string }` |
| `keyword(word, handler)` | Substring match | Async (fire-and-forget) | `{ reply?: string }` or `null` |
| `pattern(regex, handler)` | Regex match | Async (fire-and-forget) | `{ reply?: string }` or `null` |
| `event(type, handler)` | Platform event | Async (fire-and-forget) | `{ reply?: string }` or `null` |
| `timer(name, handler)` | Interval clock | Async (self-triggered) | `{ reply?: string }` or `null` |
| `moderate(handler)` | Every message | Sync (500ms deadline) | `{ block: boolean }` |
## Test locally
Start the bot module dev server:
```bash
lumio dev --bot-module
```
This launches a chat simulator where you can type messages and see handler responses in real time. See [Local Development](/guides/bot-module-local-dev) for details.
You can also test individual handlers:
```bash
lumio run command:hello --args "" --user "testuser"
```
## Deploy
Build and deploy like any other extension:
```bash
lumio deploy
```
After deployment, the bot module appears in the Lumio Store under the **Bot Module** category. Users install it from the Store and configure triggers, cooldowns, and settings in their dashboard.
## What happens at runtime
1. A chat message arrives at the platform bot (Twitch, YouTube, etc.)
2. The bot runs built-in modules first (link protection, word filter, spam protection)
3. The bot matches the message against installed extension triggers (commands, keywords, patterns)
4. On match, the bot dispatches to the Bot Module Worker via HTTP (sync) or Redis (async)
5. The Worker executes your handler in a V8 isolate
6. The handler response (reply, moderation action) is sent back to the bot
7. The bot delivers the response to chat
## Next steps
- [Local Development](/guides/bot-module-local-dev) — chat simulator, hot-reload, mock users
- [Bot Module Context](/sdk/hooks/bot-module-context) — full `ctx` API reference
- [Triggers](/config/triggers) — trigger configuration reference
- [Security](/guides/bot-module-security) — rate limits, permissions, audit log
- [Cache & Background Jobs](/guides/cache-and-defer) — `ctx.cache` and `ctx.defer()` usage
- [Examples](/examples/loyalty-points) — complete bot module examples
---
## Guides / Bot Module Local Development
# Bot Module Local Development
Bot modules have no browser UI, so `lumio dev` provides a CLI-based chat simulator for interactive testing. This guide covers the dev server, single-handler testing, hot-reload, and mock users.
## Start the dev server
```bash
lumio dev --bot-module
```
Output:
```
Bot Module Dev Server running
Extension: My Bot Module (install: dev-local)
Platform: twitch (simulated)
Type chat messages below. Prefix with @username to set sender.
>
```
The dev server:
- Loads your `lumio.config.json` and `server/functions.ts`
- Connects to your local Extensions-DB for `ctx.db` operations
- Connects to local Redis for `ctx.cache` operations
- Watches `server/functions.ts` for changes and hot-reloads automatically
- Fires timers at their configured intervals
## Interactive chat
Type messages at the prompt to simulate chat input:
```
> !hello
[hello] Reply: "Hello, dev_user!"
> gg
[hypeDetector] (silent - no reply)
[hypeDetector] Deferred: ctx.cache.increment("hype_count", 1)
> https://clips.twitch.tv/FunnyClip123
[clipLogger] Reply: "Clip from dev_user: https://clips.twitch.tv/FunnyClip123"
```
The simulator shows handler responses, actions, and deferred jobs in real time.
## Mock users
Prefix your message with `@username` to simulate different senders:
```
> @viewer_jane !hello
[hello] Reply: "Hello, viewer_jane!"
```
Set roles with a role prefix before the username:
| Prefix | Role |
|--------|------|
| `@username` | `everyone` (default) |
| `@sub:username` | `subscriber` |
| `@vip:username` | `vip` |
| `@mod:username` | `moderator` |
| `@broadcaster:username` | `broadcaster` |
Examples:
```
> @sub:loyal_viewer !gamble 50
[gamble] Reply: "loyal_viewer won 100 points!"
> @mod:nightbot !clear
[clear] Reply: "Chat cleared by nightbot"
```
The simulated user object populates `ctx.user` with the appropriate `id`, `name`, `role`, `isMod`, `isVip`, and `isSub` fields.
## Single handler testing
Test a specific handler directly without the interactive chat:
```bash
# Test a command handler with arguments
lumio run command:hello --args "" --user "testuser" --role "subscriber"
# Test a keyword handler
lumio run keyword:hypeDetector --message "gg wp nice one" --user "viewer123"
# Test a pattern handler
lumio run pattern:clipLogger --message "check this https://clips.twitch.tv/abc123" --user "clipper"
# Test an event handler
lumio run event:welcomeSub --event '{"user_name": "newSub", "cumulative_months": 3}'
# Test a moderation handler
lumio run moderate:linkFilter --message "go to banned-site.com for free stuff" --user "spammer"
```
Each `lumio run` invocation:
1. Loads the extension config and handler code
2. Creates a mock context with the specified user and role
3. Executes the handler once
4. Prints the response and any side effects (DB writes, cache ops, deferred jobs)
5. Exits
## Hot-reload
The dev server watches `server/functions.ts` and related files. When you save a change, handlers are reloaded automatically:
```
[hot-reload] Detected change in server/functions.ts
[hot-reload] Handlers reloaded (3 commands, 2 keywords, 1 timer)
```
The reload preserves your chat history and in-memory state. Timers are restarted with potentially new intervals.
## Timer testing
Timers fire at their configured intervals during `lumio dev`. For faster iteration, you can trigger a timer manually:
```
> /timer reminder
[periodicReminder] Reply: "Remember to follow the channel!"
```
The `/timer ` command fires the named timer handler immediately without waiting for the interval.
## Platform simulation
By default, the dev server simulates Twitch. Switch platforms with:
```
> /platform youtube
Platform switched to: youtube
> /platform kick
Platform switched to: kick
```
This changes the `ctx.user.platform` value for subsequent messages.
## Database and cache
The dev server uses your local infrastructure:
- **Database:** Local Extensions-DB (port 5434). Tables from `server/schema.ts` are provisioned automatically.
- **Cache:** Local Redis. Cache keys are scoped under `ext-cache:dev-local:*`.
Both persist across dev server restarts, so you can build up test data over time.
## Debugging
The dev server prints detailed output for each handler invocation:
```
> !points
[points] Executing command handler...
[points] ctx.cache.get("pts:viewer123") → 150
[points] ctx.cache.set("pts:viewer123", 151, 3600) → OK
[points] Reply: "viewer123: 151 points"
[points] Deferred: 1 job queued
[points] Deferred[0]: ctx.db.patch("user_points", "viewer123", {"balance": 151}) → OK
[points] Completed in 8ms
```
This shows every `ctx.db`, `ctx.cache`, and `ctx.fetch` call made by the handler, along with deferred job execution.
## Production debugging
After deploying your bot module, use `lumio logs` to stream production handler execution logs:
```bash
# Stream all logs
lumio logs
# Filter by handler
lumio logs --handler points
# Filter by level
lumio logs --level error
```
See [Error Reporting](/guides/error-reporting) for information about the anonymized error reports available in the developer dashboard.
---
## Guides / Bot Module Security
# Bot Module Security
Bot modules execute third-party code that can send chat messages and perform moderation actions. Lumio enforces multiple security layers to protect streamers, viewers, and the platform.
## V8 isolation
Every handler invocation runs inside an isolated V8 sandbox with strict resource limits:
| Limit | Sync path (commands, moderation) | Async path (keywords, patterns, events, timers) |
|-------|----------------------------------|--------------------------------------------------|
| CPU timeout | 500ms | 10s |
| Memory | 256 MB heap | 256 MB heap |
| Response size | 4 MB | 4 MB |
Each extension install gets its own V8 isolate. Isolates share no state — one extension cannot access another extension's memory, variables, or closures.
## Database isolation
Each extension install gets its own PostgreSQL schema (`ext_{install_id}`). Extensions cannot query core Lumio tables (users, accounts, overlays) or other extensions' schemas.
## Cache isolation
`ctx.cache` operations are scoped to `ext-cache:{install_id}:*` in Redis. The Rust worker prepends this prefix before executing any Redis command. Extensions cannot access other extensions' cache keys or any internal Lumio Redis keys.
## Egress restrictions
`ctx.fetch()` only allows HTTPS requests to hostnames declared in the `egress.allowHosts` manifest field. Private IP ranges (`10.*`, `172.16-31.*`, `192.168.*`, `127.*`, link-local) are blocked at two layers:
1. **URL hostname check** — literal IP addresses in the URL are rejected
2. **Post-DNS-resolution check** — after DNS resolution, every resolved IP is validated against private ranges, preventing DNS rebinding attacks
## Redis isolation
Extension JavaScript has no direct Redis access. There is no `ctx.redis` API. All Redis operations (`ctx.cache`, cooldowns, rate limits, pub/sub) are performed by trusted Rust code in the Worker. Redis keys are constructed from validated install IDs — extension input is never interpolated into key names.
## Permission tiers
Extensions declare required permissions in `lumio.config.json`. Each permission goes through store review:
| Permission | Review level | Notes |
|------------|-------------|-------|
| `chat:read` | Standard | Reading chat messages |
| `chat:send` | Standard | Sending chat replies |
| `events:read` | Standard | Receiving platform events |
| `chat:delete` | Enhanced manual review | Deleting chat messages |
| `chat:ban` | Enhanced manual review | Banning or timing out users |
Extensions requesting `chat:ban` or `chat:delete` undergo additional manual code review during the store approval process. Reviewers verify that moderation logic is sound and that the extension cannot be exploited to mass-ban or mass-delete.
## Rate limits
All actions are rate-limited per installation to prevent abuse:
### Handler invocation limits
| Category | Limit | Scope |
|----------|-------|-------|
| Command execution | 60/min | Per install |
| Keyword/pattern execution | 100/min | Per install |
| Event handler execution | 50/min | Per install |
| Timer execution | 1/interval | Per install |
### Action output limits
| Action | Limit | Scope |
|--------|-------|-------|
| Chat send | 30/min | Per install |
| Ban | 5/min | Per install |
| Timeout | 10/min | Per install |
| Delete message | 20/min | Per install |
| Cache operations | 200/min | Per install |
Rate limits are enforced at two layers:
1. **Input:** Limits how often a handler is invoked
2. **Output:** Limits what actions the handler can produce, regardless of which handler type generated them
A keyword handler that returns a ban action is still subject to the 5/min ban limit. This prevents bypassing moderation rate limits by splitting actions across handler types.
## Trigger limits
Each extension is limited in how many triggers it can declare:
| Trigger type | Maximum per extension |
|-------------|----------------------|
| Commands | 20 |
| Keywords | 50 |
| Patterns (regex) | 10 |
| Keyword minimum length | 3 characters |
| Pattern maximum length | 200 characters |
Regex patterns are validated for safety. The Rust `regex` crate guarantees linear-time matching (no backreferences or lookaheads), making ReDoS attacks impossible by construction. Pattern matching runs in Rust on the bot side — JavaScript `RegExp` is never used for trigger matching.
## Moderation fail-closed policy
When the sync moderation path times out (500ms), the bot applies a configurable default policy:
| Policy | Behavior |
|--------|----------|
| `allow` (default) | Message passes through |
| `hold` | Message is held and auto-deleted after 5 seconds if no response |
The policy is configured per account in bot module settings, not per extension. Most streamers use `allow` to avoid false positives from slow responses.
## Audit log
Every moderation action executed by a bot module extension is logged to the platform audit log with:
- **Source:** `extension:{install_id}`
- **Metadata:** Extension name, extension ID, handler name
- **Action:** The specific moderation action (ban, timeout, delete)
- **Reason:** Prefixed with the extension name for traceability
Account owners can view extension-originated moderation actions separately from manual and built-in module actions in the dashboard audit log.
## Kill switch
Every account has a "Pause all extension modules" toggle in the dashboard. When activated:
1. A signal is published to all connected bots and the Worker
2. All extension bot module processing stops immediately for that account
3. All pending deferred jobs for that account are cancelled immediately
4. Built-in modules (link protection, word filter, spam protection) continue operating normally
The kill switch is designed for emergencies — if a malicious extension starts banning legitimate users, the streamer can stop all extension modules with one click.
## Built-in module priority
Built-in modules (LinkProtection, WordFilter, SpamProtection) always run before extension modules. If a built-in module blocks a message, extension `moderate()` handlers are not invoked. Built-in modules execute in under 1ms with no V8 overhead.
## Error isolation
Handler errors are contained within the isolate:
| Scenario | Behavior |
|----------|----------|
| Handler throws an exception | Error logged, no message sent to chat |
| Handler times out (sync) | Command silently dropped, moderation applies default policy |
| Handler times out (async) | Handler killed, error logged |
| Handler returns invalid data | Treated as null response, warning logged |
| `ctx.db` error | Promise rejects inside handler |
| `ctx.fetch` blocked | Promise rejects with `EgressDenied` error |
| Rate limit exceeded | Handler not invoked, message silently dropped |
---
## Guides / Bot Module Updates
# Bot Module Updates
When you publish a new version of your bot module, Lumio updates all installations while preserving every customization the user has made. This page explains the update lifecycle and the override resolution system.
## Override resolution
Every configurable field follows a single rule:
```
resolved_value = COALESCE(user_override, extension_default)
```
If the user has set a custom value, that value is used. If not, the extension default is used. User overrides are never overwritten by extension updates.
## What users can override
Users customize bot modules through the dashboard settings panel:
| Setting | Extension provides | User can override |
|---------|-------------------|-------------------|
| Command cooldown (global) | Default value | Custom value |
| Command cooldown (per-user) | Default value | Custom value |
| Command min role | Default value | Raise (not lower) the minimum role |
| Command enabled | `true` | Toggle on/off |
| Command alias | Name from manifest | Alternative name (e.g. `punkte` for `points`) |
| Keyword enabled | `true` | Toggle on/off per keyword |
| Custom keywords | None | User-added keywords |
| Timer interval | Default interval | Custom interval |
| Timer enabled | `true` | Toggle on/off |
| Config values | Defaults from `config_schema` | Custom values |
## Update flow
When you publish a new version:
```
Developer publishes new version
|
v
API marks version as "published"
|
v
Auto-update enabled?
-> Yes: automatic install
-> No: Update badge in dashboard, user clicks "Update"
|
v
extension_installs.version_id updated
|
v
Three things happen:
1. Config migration
2. Trigger sync
3. Worker + bot reload
```
### 1. Config migration (non-destructive)
| Change | Result |
|--------|--------|
| New `config_schema` field added | Default value inserted (user has no override yet) |
| `config_schema` field removed | User override kept as dead data, cleaned on next save |
| Default value changed | No effect on users with existing overrides |
| Default value changed | Users without overrides get the new default |
### 2. Trigger sync (non-destructive)
| Change | Result |
|--------|--------|
| New command added | Appears with extension defaults, no user override |
| Command removed | User overrides for that command are cleaned up |
| Command renamed | Old name removed (with override cleanup), new name appears with defaults |
| Cooldown/role defaults changed | No effect on users with overrides |
| New keyword added | Appears enabled by default |
| Keyword removed | User overrides cleaned up |
| New pattern added | Appears enabled by default |
| Pattern removed | Cleaned up |
| New timer added | Appears enabled with default interval |
| Timer removed | User overrides cleaned up |
| Timer interval changed | No effect on users with custom intervals |
| Handler logic changed | New code loaded, no config impact |
User-added custom keywords are always preserved across updates.
### 3. Worker and bot reload
After the update:
- Bots reload the extension trigger lists (commands, keywords, patterns, moderation flag)
- The Worker invalidates its V8 cache, loads the new handler code, and restarts timers with current intervals
## Update safety matrix
| What changed | User has override? | Result |
|--------------|-------------------|--------|
| Default cooldown 5s to 3s | Yes (10s) | User keeps 10s |
| Default cooldown 5s to 3s | No | User gets new default 3s |
| New command added | N/A | Appears with extension defaults |
| Command removed | Yes | Override cleaned up, command gone |
| New config field | N/A | Default value inserted |
| Config field removed | Yes | Override kept (dead data, cleaned on save) |
| Keyword added | N/A | Enabled by default |
| Keyword removed | Yes (disabled) | Override cleaned up |
| User-added keyword | N/A | Preserved, never touched by updates |
| Handler logic changed | N/A | New code loaded, no config impact |
| Timer interval changed | Yes (custom) | User keeps custom interval |
| Timer interval changed | No | User gets new default |
| New timer added | N/A | Appears enabled with default interval |
## Best practices for updates
### Adding new features
When adding new commands, keywords, or config fields, users get sensible defaults automatically. No migration code is needed.
```json
{
"triggers": {
"commands": [
{ "name": "points", "description": "Existing command" },
{ "name": "leaderboard", "description": "New command in v1.1.0" }
]
},
"config_schema": [
{ "key": "pointsPerMessage", "type": "number", "label": "Points per message", "default_value": 1 },
{ "key": "leaderboardSize", "type": "number", "label": "Leaderboard entries", "default_value": 10 }
]
}
```
### Removing features
When removing a command or config field, the user's overrides are cleaned up automatically. No manual cleanup code is needed.
### Renaming commands
If you rename a command (e.g., `!pts` to `!points`), the old command is removed and the new one appears with defaults. Users who had aliases on the old command will need to reconfigure them. Consider this when renaming commands in minor versions.
### Changing defaults
Changing a default cooldown or config value only affects users who have not customized that setting. Users with overrides keep their customized values. This means you can safely adjust defaults without disrupting existing users.
---
## Guides / Discord Slash Commands
# Discord Slash Commands
When a bot module declares `"discord"` in its `platforms` array, its commands are automatically registered as Discord slash commands. This page explains the mapping, `discord_options`, and the registration lifecycle.
## How it works
Extension developers declare commands once in `lumio.config.json`. Lumio handles the platform-specific dispatch:
| Platform | User types | Matching method |
|----------|-----------|-----------------|
| Twitch | `!points 100` | IRC prefix match |
| YouTube | `!points 100` | Chat prefix match |
| Kick | `!points 100` | Chat prefix match |
| Trovo | `!points 100` | Chat prefix match |
| Discord | `/points amount:100` | Slash command interaction |
Your handler code is identical for all platforms. The `args` array is populated the same way regardless of where the command originated:
- Prefix command `!points 100` produces `args: ["100"]`
- Discord slash command `/points amount:100` produces `args: ["100"]` (positional from option order)
## Registration flow
When an extension with Discord support is installed on an account that has a Discord connection:
1. The API reads the extension manifest commands
2. The API registers Discord slash commands via the Discord API (`POST /applications/{app_id}/guilds/{guild_id}/commands`)
3. The Discord bot receives interactions via the Discord Gateway
4. The bot dispatches to the Worker using the same sync HTTP path as prefix commands
Discord may take up to 1 hour to propagate guild-level command changes.
## Adding `discord_options`
By default, commands are registered as simple slash commands with no options. To add typed parameters, use the `discord_options` field:
```json
{
"triggers": {
"commands": [
{
"name": "points",
"description": "Show a user's points balance",
"cooldown_global": 5,
"min_role": "everyone",
"discord_options": [
{
"name": "user",
"type": "user",
"description": "User to check",
"required": false
}
]
},
{
"name": "give",
"description": "Give points to another user",
"cooldown_global": 10,
"min_role": "moderator",
"discord_options": [
{
"name": "user",
"type": "user",
"description": "Recipient",
"required": true
},
{
"name": "amount",
"type": "integer",
"description": "Points to give",
"required": true
}
]
}
]
}
}
```
### Option types
| Type | Discord type | Description |
|------|-------------|-------------|
| `"string"` | `STRING` | Text input |
| `"integer"` | `INTEGER` | Whole number |
| `"boolean"` | `BOOLEAN` | True/false toggle |
| `"user"` | `USER` | Discord user mention picker |
| `"channel"` | `CHANNEL` | Channel picker |
| `"role"` | `ROLE` | Role picker |
Non-Discord platforms ignore the `discord_options` field entirely.
### Args mapping
Discord options are mapped to the `args` array by position (order of declaration):
```json
{
"discord_options": [
{ "name": "user", "type": "user", "required": true },
{ "name": "amount", "type": "integer", "required": true }
]
}
```
When a user runs `/give user:@jane amount:50`, the handler receives `args: ["jane_user_id", "50"]` — matching the declaration order.
For prefix platforms, `!give @jane 50` produces the same `args: ["@jane", "50"]`.
## Handling Discord-specific behavior
Your handler can check `ctx.user.platform` to detect which platform triggered the command:
```typescript
import { command } from "@zaflun/lumio-sdk/server";
export const points = command("points", async (ctx, args) => {
const targetUser = args[0] ?? ctx.user.id;
const points = await ctx.db.get("user_points", targetUser);
if (ctx.user.platform === "discord") {
// Discord supports richer formatting
return { reply: `**${ctx.user.displayName}** has **${points?.balance ?? 0}** points` };
}
return { reply: `${ctx.user.displayName} has ${points?.balance ?? 0} points` };
});
```
## Sync on update
When you publish a new version that changes commands:
1. Old Discord slash commands are removed
2. New commands are registered with the Discord API
3. Discord propagates changes (up to 1 hour for guild commands)
During the propagation window, users may see stale commands in the Discord autocomplete. This is a Discord API limitation.
## Discord-only triggers
Keywords and patterns work in Discord the same as other platforms — they match against message content in text channels. Events use Discord-specific event types if available.
Timers fire to Discord channels the same way they fire to other platforms.
---
## Guides / Cache & Background Jobs
# Cache & Background Jobs
Bot modules (and all extension types) have access to two complementary APIs for performance optimization: `ctx.cache` for fast Redis-backed temporary storage, and `ctx.defer()` for queuing background work after the handler returns.
Each API works independently. You can use cache without defer, defer without cache, or combine them for patterns like cache-first reads with background DB persistence.
## `ctx.cache` — Scoped Redis cache
A key-value cache backed by Redis, scoped to the extension installation. Provides sub-millisecond reads and writes without the overhead of a database round-trip.
### API
```typescript
ctx.cache = {
get: (key: string) => Promise,
set: (key: string, value: unknown, ttl?: number) => Promise,
delete: (key: string) => Promise,
increment: (key: string, by?: number) => Promise,
};
```
### Basic usage
```typescript
import { command } from "@zaflun/lumio-sdk/server";
export const counter = command("count", async (ctx) => {
// Increment a counter (creates the key with value 0 if it does not exist)
const count = await ctx.cache.increment("message_count", 1);
return { reply: `Message count: ${count}` };
});
```
### Setting values with TTL
```typescript
// Store a value with a 5-minute TTL
await ctx.cache.set("last_clipper", ctx.user.name, 300);
// Store a value with the default TTL (1 hour)
await ctx.cache.set("status", "active");
// Read it back
const status = await ctx.cache.get("status"); // "active" or null if expired
```
### Deleting values
```typescript
await ctx.cache.delete("spam_score:user123");
```
### Key scoping
Your extension only provides the key name. The Rust Worker automatically prepends `ext-cache:{install_id}:` before executing any Redis command:
```
Extension calls: ctx.cache.set("score:user123", 42, 3600)
Redis executes: SET ext-cache:{install_id}:score:user123 42 EX 3600
```
You cannot access other extensions' cache keys or any internal Lumio Redis keys.
### Key format rules
Keys must match the pattern `^[a-zA-Z0-9:_-]+$` — alphanumeric characters plus `:`, `_`, and `-`. This validation is enforced in Rust (not JavaScript), so it cannot be bypassed.
### Limits
| Limit | Value |
|-------|-------|
| Max TTL | 24 hours (86,400 seconds) |
| Default TTL | 1 hour (3,600 seconds) |
| Max key length | 128 characters |
| Max value size | 64 KB |
| Max keys per install | 1,000 |
| Operations rate limit | 200 ops/min per install |
| No-TTL behavior | Keys without explicit TTL get the 1-hour default |
There are no wildcard operations (`KEYS`, `SCAN`). Extensions cannot enumerate their own keys.
### When to use cache vs database
| Use case | Use `ctx.cache` | Use `ctx.db` |
|----------|----------------|-------------|
| Cooldown counters | Yes | No |
| Spam scores | Yes | No |
| "Last chatter" queue | Yes | No |
| Temporary vote counts | Yes | No |
| User point balances (fast reads) | Yes (with DB backup) | Yes (source of truth) |
| Leaderboards | No | Yes |
| Permanent user data | No | Yes |
| Historical logs | No | Yes |
---
## `ctx.defer()` — Background jobs
Queues a function that the Worker executes **after** the handler returns. The handler can respond to the user immediately while expensive work runs in the background — outside the 500ms sync deadline.
### API
```typescript
ctx.defer(fn: () => Promise): void
```
### Basic usage
```typescript
import { command } from "@zaflun/lumio-sdk/server";
export const points = command("points", async (ctx, args) => {
const balance = 100; // fast calculation
// Reply immediately
// Background work runs after this return
ctx.defer(async () => {
// This runs after the reply is sent
await ctx.db.insert("point_history", {
user_id: ctx.user.id,
action: "check",
timestamp: Date.now(),
});
});
return { reply: `${ctx.user.name}: ${balance} points` };
});
```
### Limits
| Limit | Value |
|-------|-------|
| Max deferred calls per handler | 5 |
| Timeout per deferred call | 10 seconds |
| Available APIs | `ctx.db`, `ctx.cache`, `ctx.fetch`, `ctx.secrets` |
| Error handling | Logged to Sentry, not surfaced to the chat user |
### Multiple deferred calls
You can queue up to 5 deferred calls per handler invocation. They execute sequentially after the handler returns:
```typescript
export const gamble = command("gamble", async (ctx, args) => {
const result = Math.random() > 0.5 ? "win" : "lose";
ctx.defer(async () => {
await ctx.db.patch("user_points", ctx.user.id, {
balance: result === "win" ? 200 : 50,
});
});
ctx.defer(async () => {
await ctx.db.insert("gamble_history", {
user_id: ctx.user.id,
result,
timestamp: Date.now(),
});
});
return { reply: `${ctx.user.name} ${result === "win" ? "won" : "lost"}!` };
});
```
### Error handling
If a deferred call throws an exception or times out, the error is logged but does not affect the user. The chat reply has already been sent. Errors from deferred calls appear in the developer dashboard error reports and `lumio logs`.
---
## Combined pattern: cache-first with background persist
The most common pattern combines `ctx.cache` for fast reads with `ctx.defer()` for background database writes:
```typescript
import { command } from "@zaflun/lumio-sdk/server";
export const points = command("points", async (ctx, args) => {
// Fast: read from cache (~1ms)
const cached = (await ctx.cache.get(`pts:${ctx.user.id}`)) as number | null;
const balance = cached ?? 0;
const newBalance = balance + 1;
// Fast: write to cache (~1ms)
await ctx.cache.set(`pts:${ctx.user.id}`, newBalance, 3600);
// Slow: persist to DB in background (~5-20ms, runs after reply)
ctx.defer(async () => {
await ctx.db.patch("user_points", ctx.user.id, { balance: newBalance });
await ctx.db.insert("point_history", {
user_id: ctx.user.id,
amount: 1,
timestamp: Date.now(),
});
});
return { reply: `${ctx.user.name}: ${newBalance} points` };
});
```
This pattern keeps the handler response under the 500ms sync deadline while ensuring data eventually reaches the database.
### Comparison
| | `ctx.cache` | `ctx.db` | `ctx.defer()` |
|---|------------|---------|--------------|
| Backend | Redis | PostgreSQL | V8 (background) |
| Persistence | Temporary (TTL) | Permanent | N/A (orchestrator) |
| Latency | ~1ms | ~5-20ms | Async (after return) |
| Use case | Counters, cooldowns | User data, leaderboards | Background jobs |
---
## Guides / Error Reporting
# Error Reporting
When your bot module (or any extension) throws errors in production, Lumio collects anonymized error reports and makes them available in the developer dashboard. You get full visibility into what went wrong without accessing any personally identifiable information from the accounts that have your extension installed.
## What you can see
The developer error dashboard shows:
| Data | Visible | Example |
|------|---------|---------|
| Error message | Yes (sanitized) | `TypeError: Cannot read property 'balance' of null` |
| Stack trace | Yes | `at points (server/functions.ts:5:42)` |
| Handler name | Yes | `points` |
| Trigger type | Yes | `command` |
| Platform | Yes | `twitch` |
| Extension version | Yes | `1.2.0` |
| Sanitized arguments | Yes | `["100"]` |
| Config key names | Yes | `[pointsPerMessage, gamblingEnabled]` |
| User role | Yes | `subscriber` |
| Execution time | Yes | `12ms` |
| Occurrence count | Yes | `34` |
| Affected install count | Yes | `3 of 150` |
| Anonymous account hash | Yes | `anon_a1b2c3` |
| Anonymous install hash | Yes | `anon_d4e5f6` |
## What you cannot see
| Data | Why |
|------|-----|
| Account ID | HMAC-hashed, not reversible |
| Install ID | HMAC-hashed, not reversible |
| User ID | Stripped entirely, never stored |
| Username / display name | Stripped entirely |
| Channel name | Stripped entirely |
| IP address | Never collected |
| Chat message content | Stripped entirely |
| Config values | Masked to `*` |
| Platform message ID | Stripped entirely |
## Anonymization details
### HMAC account hashing
Account and install IDs are hashed using HMAC-SHA256 with a per-extension salt (auto-generated, not accessible to you). The resulting `anon_*` hashes are:
- **Consistent** — the same account always produces the same hash, so you can group errors by account
- **Not reversible** — you cannot determine which account generated the error
- **Per-extension** — different extensions produce different hashes for the same account
### Error message sanitization
Error messages are sanitized before storage:
- UUIDs matching `[0-9a-f]{8}-...` are replaced with `[UUID]`
- `@username` mentions are replaced with `[USER]`
- URLs containing tokens or auth parameters are replaced with `[URL]`
- Strings longer than 50 characters in `args` are truncated
### Config value masking
Config key names are visible (you defined them in `config_schema`), but all values are masked to `*`. This prevents accidental PII exposure from free-text config fields.
### Error grouping
Errors are grouped by a hash of `handler + sanitized_error_message + first_line_of_stack`. The same error from different accounts and users groups under one entry, with occurrence counts and affected install counts.
## Using error reports for debugging
### Identifying account-specific issues
The `anon_account` hash lets you see error distribution:
```
Error: TypeError: Cannot read property 'balance' of null
Occurrences: 34
anon_account anon_a1b2c3: 28 hits
anon_account anon_x7y8z9: 4 hits
anon_account anon_m3n4o5: 2 hits
```
28 of 34 errors from the same anonymous account suggests a config-specific issue — perhaps that account has an unusual config combination.
### Identifying platform-specific issues
Filter by platform to see if an error only occurs on a specific chat platform.
### Identifying version-specific issues
The version field shows when an error was introduced. If errors spike after a version release, you can quickly identify the regression.
### Identifying role-specific issues
The user role field (subscriber, moderator, etc.) can reveal issues that only affect certain permission levels.
## Retention
Error reports are retained for 30 days, then automatically deleted.
## Account owner view
Accounts that have your extension installed see their own errors and logs — not anonymized, since it is their own data. This is separate from the developer view:
| View | Who sees it | Anonymized? | Scope |
|------|------------|-------------|-------|
| Developer Error Dashboard | Extension developer | Yes | All installs, all accounts |
| Account Install Logs | Account owner | No (own data) | Only this account's install |
Account owners see their own usernames, chat content, and config values. Extension secrets and API keys are always masked regardless of viewer.
## Accessing error reports
### Developer dashboard
Navigate to your extension in the developer dashboard and open the Errors tab.
### CLI
```bash
lumio logs --level error
```
### API
Error reports are available via the REST API:
```
GET /v1/developer/extensions/{extension_id}/errors?since=2026-05-19T00:00:00Z&handler=points
```
This endpoint requires the `feature:extension_development` feature flag and extension ownership verification.