Viewer Input
Viewers interact with an extension through chat commands and channel point rewards (Twitch and Kick). There is no viewer page and no viewer identity: an extension renders only on the editor and layer surfaces, and a viewer's input always arrives as a chat message or a stream event that a bot module handles.
A poll that viewers vote in works like this:
Viewer types "!vote 2" Viewer redeems the "Vote" reward
| |
Platform bot matches the command API publishes the stream event
(min_role, cooldowns) on lumio:events:{account_id}
| |
+-------> Bot Module Worker <-------+
runs command("vote") / event("twitch:reward")
-> ctx.db.insert("votes", ...)
-> ctx.realtime.push("poll:votes", ...)
|
WebSocket channel extension_install:{install_id}
|
editor + layer: useRealtime("poll:votes", cb)
Manifest
Viewer input needs a bot_module extension. List the command and the reward event types in triggers; add editor and layer to targets when the extension also shows the result:
{
"$schema": "https://lumio.vision/schemas/lumio.config.schema.json",
"extension_id": "your-uuid-here",
"name": "Chat Poll",
"category": "bot_module",
"version": "1.0.0",
"platforms": ["twitch", "kick"],
"targets": ["editor", "layer"],
"server": true,
"permissions": ["chat:send"],
"triggers": {
"commands": [
{
"name": "vote",
"description": "Vote for a poll option, e.g. !vote 2",
"cooldown_user": 10,
"min_role": "everyone"
}
],
"events": ["twitch:reward", "kick:reward"]
}
}
See Triggers for every field.
Schema
ctx.db writes to the tables of your schema:
// server/schema.ts
import { defineSchema, defineTable, v } from "@zaflun/lumio-sdk/server";
export default defineSchema({
votes: defineTable({
option: v.string(),
via: v.string(),
}),
});
Handlers
// server/functions.ts
import { command, event } from "@zaflun/lumio-sdk/server";
// "!vote 2" in Twitch or Kick chat
export const vote = command("vote", async (ctx, args) => {
const option = args[0];
if (!option) return { reply: "Usage: !vote <option>" };
await ctx.db.insert("votes", { option, via: "chat" });
ctx.realtime.push("poll:votes", { option });
return { reply: `${ctx.user?.displayName} voted ${option}` };
});
// A channel point reward redemption on Twitch
export const twitchReward = event("twitch:reward", async (ctx, evt) => {
const raw = evt.raw as { user_input?: string; reward?: { title?: string } };
if (raw.reward?.title !== "Vote" || !raw.user_input) return null;
await ctx.db.insert("votes", { option: raw.user_input, via: "twitch:reward" });
ctx.realtime.push("poll:votes", { option: raw.user_input });
return null;
});
// A channel point reward redemption on Kick
export const kickReward = event("kick:reward", async (ctx, evt) => {
const raw = evt.raw as { user_input?: string; reward?: { title?: string } };
if (raw.reward?.title !== "Vote" || !raw.user_input) return null;
await ctx.db.insert("votes", { option: raw.user_input, via: "kick:reward" });
ctx.realtime.push("poll:votes", { option: raw.user_input });
return null;
});
Chat commands
The platform bot matches the command and enforces min_role and the cooldowns before it calls the Bot Module Worker, which runs the command() handler. A sender below min_role, or a command on cooldown, is ignored and the handler does not run. args holds the words after the command; ctx.user is the chat sender (see Bot Module Context).
Channel point rewards
The API publishes every stream event of the account on the Redis channel lumio:events:{account_id}. The Bot Module Worker runs the event() handlers of every install whose manifest lists the event type in triggers.events:
- The account comes from the channel, never from the event payload.
- Each event runs once per install, however many worker replicas run.
- At most 50 event and timer runs per install and minute; at most 32 event handlers in flight per worker. A run beyond either limit is dropped.
The handler receives the stored event. evt.raw holds the platform payload:
| Event | Fields in evt.raw |
|---|---|
twitch:reward | The EventSub redemption: user_name, user_input, status, reward { id, title, cost, prompt } |
kick:reward | user_id, user_login, user_name, status, user_input, reward { id, title, cost, prompt } |
ctx.user is null in event handlers. Check reward.id or reward.title to react only to your reward: the handler receives every redemption of the channel.
Pushing to the surfaces
ctx.realtime.push(topic, payload) sends a message to every rendered surface of the install. The worker publishes the pushes after the handler returns successfully, on the WebSocket channel extension_install:{install_id}; a handler that throws publishes nothing.
| Rule | Value |
|---|---|
| Topic | ^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$ |
| Payload | Any JSON value, at most 16 KiB serialized |
| Pushes per handler run | 32 (one more throws) |
| Pushes per install and minute | 300; more are dropped |
| Published bytes per install and minute | 1 MiB of serialized { topic, payload } messages; more are dropped |
Layer
The layer (and the editor) receive the pushes with useRealtime:
// src/layer.tsx
import { useState } from "react";
import { Lumio, Text, VStack, useRealtime } from "@zaflun/lumio-sdk";
function PollLayer() {
const [counts, setCounts] = useState<Record<string, number>>({});
useRealtime<{ option: string }>("poll:votes", ({ option }) => {
setCounts((prev) => ({ ...prev, [option]: (prev[option] ?? 0) + 1 }));
});
return (
<VStack>
{Object.entries(counts).map(([option, count]) => (
<Text key={option} content={`Option ${option}: ${count}`} />
))}
</VStack>
);
}
Lumio.render(<PollLayer />, { target: "layer" });
Pushes are not stored or replayed: a surface that opens later sees only the pushes after it started. Load the current totals from ctx.db with useQuery when the surface starts, and apply useRealtime messages on top.
Try it locally
Run lumio dev --bot-module in this project and open the printed sandbox URL. Its editor and layer use the same mock host as the bot-module runtime. In a second terminal, send a vote and a reward redemption:
lumio trigger chat "!vote 2" --user alice --role subscriber
lumio trigger reward "Vote" --user alice --input 3 --platform kick
The Chat and Rewards panels show the inputs, the Data panel shows ctx.db rows, and the layer receives ctx.realtime.push without a deployed install. You can switch to the layer with /view layer in the lumio dev terminal. A real overlay and platform connections are exercised after deployment. See lumio dev and lumio trigger.