Chat Poll
The streamer creates a two-option poll in the editor. Viewers vote with !vote 1 or !vote 2 in chat. A bot-module handler saves each vote and pushes an update to the editor and layer. Lumio has no separate viewer page; chat commands are the viewer input for this example.
Project structure
chat-poll/
├── lumio.config.json
├── server/
│ ├── schema.ts
│ └── functions.ts
└── src/
├── editor.tsx
└── layer.tsx
lumio.config.json
{
"$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 option 1 or 2",
"cooldown_user": 10,
"min_role": "everyone"
}]
}
}
server/schema.ts
Both tables use the default instance scope. Lumio adds _id, _account_id, _created_at, and _updated_at to each row.
import { defineSchema, defineTable, v } from "@zaflun/lumio-sdk/server";
export default defineSchema({
polls: defineTable({
question: v.string(),
option_a: v.string(),
option_b: v.string(),
active: v.boolean(),
}),
votes: defineTable({
poll_id: v.string(),
option: v.string(),
}),
});
server/functions.ts
The editor-only actions create and close polls. The queries load the latest poll and its votes. The command selects the newest poll, so closing it does not reactivate an older one.
import { action, arg, command, filter, queryRows, v } from "@zaflun/lumio-sdk/server";
export const getLatestPoll = queryRows("polls", {
orderBy: "created_at", orderDir: "desc", limit: 1,
});
export const getVotes = queryRows("votes", {
filter: filter("poll_id", "=", arg("pollId")),
limit: 1000,
});
export const createPoll = action({
editorOnly: true,
args: { question: v.string(), optionA: v.string(), optionB: v.string() },
handler: async (ctx, args) => {
const id = await ctx.db.insert("polls", {
question: args.question,
option_a: args.optionA,
option_b: args.optionB,
active: true,
});
ctx.realtime.push("poll:updated", { pollId: id });
return { id };
},
});
export const closePoll = action({
editorOnly: true,
args: { id: v.string() },
handler: async (ctx, args) => {
await ctx.db.patch("polls", args.id as string, { active: false });
ctx.realtime.push("poll:updated", { pollId: args.id });
return { closed: true };
},
});
export const vote = command("vote", async (ctx, args) => {
const [poll] = await ctx.db.list("polls"); // newest row first
if (!poll || !poll.active) return { reply: "No poll is open." };
const option = args[0];
if (option !== "1" && option !== "2") {
return { reply: "Use !vote 1 or !vote 2." };
}
await ctx.db.insert("votes", { poll_id: poll._id, option });
ctx.realtime.push("poll:updated", { pollId: poll._id });
return { reply: `Vote recorded for option ${option}.` };
});
ctx.db.get(table, id) takes a row's generated _id; it cannot look up a poll by a fixed key. ctx.db.list finds the newest poll, and ctx.db.insert and ctx.db.patch write rows. There is no ctx.db.set.
src/editor.tsx
import { useState } from "react";
import { Lumio, Button, Text, TextField, VStack, useMutation, useQuery, useRealtime } from "@zaflun/lumio-sdk";
interface PollRow {
_id: string;
question: string;
active: boolean;
}
function PollEditor() {
const [question, setQuestion] = useState("");
const [optionA, setOptionA] = useState("");
const [optionB, setOptionB] = useState("");
const { data, refetch } = useQuery("getLatestPoll");
const { mutateAsync: create, isLoading: creating, error: createError } = useMutation("createPoll");
const { mutateAsync: close, isLoading: closing, error: closeError } = useMutation("closePoll");
const poll = ((data as PollRow[] | null) ?? [])[0];
useRealtime("poll:updated", () => refetch());
const start = () => {
if (!question.trim() || !optionA.trim() || !optionB.trim()) return;
void create({ question: question.trim(), optionA: optionA.trim(), optionB: optionB.trim() })
.then(refetch).catch(() => {}); // error is shown below
};
return (
<VStack gap={12}>
<Text content="Chat poll" variant="heading" />
{poll?.active && <Text content={`Open: ${poll.question}`} />}
<TextField label="Question" value={question} onChange={setQuestion} />
<TextField label="Option 1" value={optionA} onChange={setOptionA} />
<TextField label="Option 2" value={optionB} onChange={setOptionB} />
<Button label="Start poll" onClick={start} disabled={creating} />
{poll?.active && <Button label="Close poll" variant="destructive" disabled={closing}
onClick={() => { void close({ id: poll._id }).then(refetch).catch(() => {}); }} />}
{(createError || closeError) && <Text content={createError || closeError || ""} />}
<Text content="Viewers vote in chat with !vote 1 or !vote 2." />
</VStack>
);
}
Lumio.render(<PollEditor />, { target: "editor" });
src/layer.tsx
The layer loads persisted rows when it opens and refetches after each poll update. Realtime messages are not stored or replayed.
import { useMemo } from "react";
import { Lumio, Text, VStack, useQuery, useRealtime } from "@zaflun/lumio-sdk";
interface PollRow {
_id: string;
question: string;
option_a: string;
option_b: string;
active: boolean;
}
interface VoteRow { poll_id: string; option: string }
function PollLayer() {
const { data: polls, refetch: refetchPoll } = useQuery("getLatestPoll");
const poll = ((polls as PollRow[] | null) ?? [])[0];
const voteArgs = useMemo(() => ({ pollId: poll?._id ?? "" }), [poll?._id]);
const { data: votes, refetch: refetchVotes } = useQuery("getVotes", voteArgs);
useRealtime("poll:updated", () => { refetchPoll(); refetchVotes(); });
if (!poll?.active) return <Text content="No poll is open." />;
const rows = (votes as VoteRow[] | null) ?? [];
const first = rows.filter((row) => row.option === "1").length;
const second = rows.filter((row) => row.option === "2").length;
return (
<VStack gap={8}>
<Text content={poll.question} variant="heading" />
<Text content={`1. ${poll.option_a}: ${first}`} />
<Text content={`2. ${poll.option_b}: ${second}`} />
<Text content="Vote with !vote 1 or !vote 2" />
</VStack>
);
}
Lumio.render(<PollLayer />, { target: "layer" });
getVotes returns at most 1,000 rows, so the displayed counts cover at most 1,000 votes. Larger polls need paginated reads or a separate aggregation design. The command cooldown limits repeat submissions; this example does not enforce one vote per viewer.