Example plugins
Complete, working plugins to copy, run and change.
Every plugin here is the real file from the SDK. Each one is loaded by ForgeCast's own test suite and driven the way a stream would drive it (chat arriving, buttons pressed, the app restarted), so what you read is what runs.
To try one: turn on Developer mode, choose Plugins → New plugin, then replace the new folder's plugin.json and main.lua with the two files below (keep the folder's name equal to the id). Saving reloads it. Getting started walks through it.
BRB break
One button for a break: switches OBS to your BRB scene, mutes your microphone and shows a countdown in your overlays. Your scene and microphone come back when the time is up, or when you press I'm back.
Needs obs, overlay, ui.dashboard, ui.notify, storage.
-- BRB break: one button for a break.
--
-- It switches OBS to your "be right back" scene, mutes your microphone and puts a countdown in
-- your overlays: add a text layer with {{plugin.brb-break.left}} to your BRB overlay (and
-- {{plugin.brb-break.message}} for the line under it). When the time is up, or you press
-- "I'm back", your scene and your microphone go back the way they were.
--
-- obs.* calls answer later: their callback gets (ok, error). A plugin can switch scenes and mute
-- audio, but never start or stop your stream.
local settings = storage.get("settings", { scene = "BRB", minutes = 5, mute = true, message = "Back in a moment!" })
local section = dashboard.section({ id = "brb-break", title = "BRB break" })
local brk = nil -- the break on now: { ends, back_to, muted, tick }
local draw
local function clock(ms)
local s = math.max(0, math.ceil(ms / 1000))
return string.format("%d:%02d", math.floor(s / 60), s % 60)
end
-- the first microphone in OBS (obs.state().audio lists every audio input)
local function mic(o)
for _, a in ipairs(o.audio or {}) do
if a.kind == "mic" then
return a
end
end
end
local function finish(why)
if not brk then
return
end
local b = brk
brk = nil
timer.cancel(b.tick)
overlay.clear()
if b.back_to then
obs.set_scene(b.back_to)
end
if b.muted then
obs.set_mute(b.muted, false)
end
if why then
ui.notify({ message = why })
end
draw()
end
local function tick()
if not brk then
return
end
local left = brk.ends - time.now()
if left <= 0 then
return finish("Break's over: welcome back!")
end
overlay.set("left", clock(left))
draw()
end
local function start()
local o = obs.state() or {}
if not o.connected then
ui.notify({ message = "Connect ForgeCast to OBS first (the OBS page).", level = "warning" })
return
end
if not util.contains(o.scenes, settings.scene) then
ui.notify({ message = 'OBS has no scene called "' .. settings.scene .. '": pick one in the list.', level = "warning" })
return
end
local m = settings.mute and mic(o) or nil
brk = {
ends = time.now() + settings.minutes * 60000,
back_to = o.scene ~= settings.scene and o.scene or nil,
muted = (m and not m.muted) and m.name or nil,
}
obs.set_scene(settings.scene, function(ok, err)
if not ok then
finish("Couldn't switch to " .. settings.scene .. ": " .. tostring(err))
end
end)
if brk.muted then
obs.set_mute(brk.muted, true)
end
overlay.set("message", settings.message)
brk.tick = timer.every(1000, tick)
tick()
end
-- you switched scenes yourself during the break: it's over, and you are where you chose to be
obs.on(Event.ObsScene, function(e)
if brk and e.scene ~= settings.scene then
brk.back_to = nil
finish()
end
end)
-- the scene list follows OBS
obs.on(Event.ObsConnected, function()
if not brk then
draw()
end
end)
-- turned off mid-break: put things back
plugin.on_unload(function()
finish()
end)
draw = function()
if brk then
section:render(ui.column({
gap = 10,
ui.row({ align = "center", justify = "between", ui.heading(clock(brk.ends - time.now())), ui.badge({ text = "On a break", tone = "warning" }) }),
ui.row({
gap = 8,
ui.button({ label = "+1 min", variant = "outline", icon = "plus", on_click = function()
brk.ends = brk.ends + 60000
tick()
end }),
ui.button({ label = "I'm back", icon = "check", on_click = function()
finish()
end }),
}),
}))
return
end
local o = obs.state() or {}
local scenes = (o.scenes and #o.scenes > 0) and o.scenes or { settings.scene }
section:render(ui.column({
gap = 10,
not o.connected and ui.text("OBS isn't connected: connect it on the OBS page.", { muted = true }) or false,
ui.row({
gap = 8,
wrap = true,
ui.select({ name = "scene", label = "BRB scene", value = settings.scene, options = scenes }),
ui.select({ name = "minutes", label = "Minutes", value = tostring(settings.minutes), options = { "2", "5", "10", "15" } }),
}),
ui.input({ name = "message", label = "Message", value = settings.message }),
ui.switch({ name = "mute", label = "Mute my microphone", checked = settings.mute }),
ui.button({ label = "Take a break", icon = "play", disabled = not o.connected, on_click = function(ev)
local v = ev.values or {}
settings.scene = v.scene or settings.scene
settings.minutes = tonumber(v.minutes) or settings.minutes
settings.message = util.trim(v.message or settings.message)
if v.mute ~= nil then
settings.mute = v.mute == true
end
storage.set("settings", settings)
start()
end }),
}))
end
draw()What it shows
- Reading OBS (
obs.state(): its scenes, the scene on air, its audio inputs) and changing it (obs.set_scene,obs.set_mute), with a callback that hears when OBS says no. - Listening to OBS (
Event.ObsScene,Event.ObsConnected): switching scenes yourself ends the break. - Showing the plugin's own values in your overlays:
overlay.set("left", "4:59")is{{plugin.brb-break.left}}in any text layer, andoverlay.clear()takes them away. - A countdown with
timer.every, and putting things back when the plugin is turned off (plugin.on_unload).
Try it
- Connect ForgeCast to OBS, and make a scene for breaks (called "BRB", or pick yours in the list).
- In your BRB overlay, add a text layer with
{{plugin.brb-break.left}}, and one with{{plugin.brb-break.message}}. - Press Take a break. OBS switches, your microphone mutes and the countdown runs on stream. I'm back ends it early.
Chat co-host
Viewers type !ask and a question, and the AI answers in one friendly line, knowing a little about your stream and your week. The answer shows in your overlays. Off until you switch it on, with a cooldown, so it never spends credits by surprise.
Needs chat.read, ai, history, overlay, ui.dashboard, storage.
-- Chat co-host: viewers ask, the AI answers on stream.
--
-- A viewer types "!ask what are we playing next?" and the AI answers in one line, knowing a little
-- about the stream (what you wrote below, and your week from history.week). The answer shows in
-- your overlays:
-- {{plugin.chat-cohost.asker}} who asked
-- {{plugin.chat-cohost.question}} what they asked
-- {{plugin.chat-cohost.answer}} the answer
--
-- Every answer is paid with your AI credits, so it is off until you switch it on, and it answers
-- one question per cooldown. ai.ask answers later: its callback gets (answer) or (nil, why).
local settings = storage.get("settings", { on = false, cooldown = 60, about = "" })
local section = dashboard.section({ id = "chat-cohost", title = "Chat co-host" })
local last_at = 0
local waiting = false
local last = nil -- { asker, question, answer } or { error }
local draw
-- what the AI is told about the stream
local function about_the_stream()
local lines = {}
if settings.about ~= "" then
lines[#lines + 1] = "About the stream: " .. settings.about
end
local week = history.week()
if week and week.streams > 0 then
lines[#lines + 1] = string.format("This week: %d streams, %d minutes live, a peak of %d viewers.", week.streams, week.minutes, week.peak)
end
local recent = history.streams({ limit = 1 })[1]
if recent and recent.title then
lines[#lines + 1] = "The last stream was called: " .. recent.title
end
return table.concat(lines, "\n")
end
chats.on(Event.NewChat, function(chat)
if not settings.on or waiting or not util.starts_with(chat.text, "!ask ") then
return
end
if time.now() - last_at < settings.cooldown * 1000 then
return
end
local question = util.trim(chat.text:sub(6)):sub(1, 300)
if #question < 3 then
return
end
last_at, waiting = time.now(), true
local system = "You are the friendly co-host of a YouTube live stream. Answer the viewer in one short sentence (at most 150 characters). If you don't know, say so kindly.\n" .. about_the_stream()
ai.ask(chat.author.name .. " asks: " .. question, { system = system }, function(answer, why)
waiting = false
if not answer then
last = { error = why }
else
last = { asker = chat.author.name, question = question, answer = answer }
overlay.set("asker", last.asker)
overlay.set("question", question)
overlay.set("answer", answer)
end
draw()
end)
draw()
end)
draw = function()
local status
if waiting then
status = ui.text("Thinking of an answer…", { muted = true })
elseif last and last.error then
status = ui.text("No answer: " .. last.error, { color = "warning" })
elseif last then
status = ui.column({ gap = 4, ui.text(last.asker .. ": " .. last.question, { muted = true, size = "sm" }), ui.text(last.answer, { bold = true }) })
else
status = ui.text("Viewers type !ask and a question.", { muted = true })
end
section:render(ui.column({
gap = 10,
ui.switch({ label = "Answer !ask (uses AI credits)", checked = settings.on, on_change = function(ev)
settings.on = ev.value == true
storage.set("settings", settings)
draw()
end }),
status,
ui.textarea({ name = "about", label = "What the co-host should know", placeholder = "We're playing Minecraft hardcore, day 12. Discord in the description.", value = settings.about, rows = 2 }),
ui.row({
gap = 8,
align = "end",
ui.select({ name = "cooldown", label = "One answer every", value = tostring(settings.cooldown), options = { { value = "30", label = "30 seconds" }, { value = "60", label = "minute" }, { value = "300", label = "5 minutes" } } }),
ui.button({ label = "Save", variant = "outline", on_click = function(ev)
local v = ev.values or {}
settings.about = util.trim(v.about or ""):sub(1, 500)
settings.cooldown = tonumber(v.cooldown) or settings.cooldown
storage.set("settings", settings)
draw()
end }),
last and not last.error and ui.button({ label = "Clear the overlay", variant = "ghost", icon = "x", on_click = function()
overlay.clear()
last = nil
draw()
end }) or false,
}),
}))
end
draw()What it shows
- Asking the AI (
ai.ask) with your own instructions (system), and handling both an answer and a failure (no credits left, AI switched off) in the callback. - Giving the AI context from your past streams:
history.week()(the last 7 days added up) andhistory.streams{ limit = 1 }(the latest one). - Putting the answer in your overlays with
overlay.set:{{plugin.chat-cohost.answer}},.askerand.question. - Spending carefully: off until the streamer switches it on, one question per cooldown, and nothing new while an answer is on its way.
Try it
- Turn on AI in ForgeCast's settings (each answer costs AI credits), then switch on Answer !ask.
- Add a text layer with
{{plugin.chat-cohost.asker}}: {{plugin.chat-cohost.question}}and one with{{plugin.chat-cohost.answer}}to an overlay. - Type
!ask what are we doing today?in chat (or in the Test environment). The answer appears on the Dashboard and in the overlay, and the request shows under AI workers.
Chat filter
Keeps chat tidy: hides links from people who aren't members, hides the same message sent twice in a row, and turns SHOUTING into normal text. Each rule has a switch on the Dashboard.
Needs chat.middleware, ui.dashboard, storage.
-- Chat filter: three small rules that keep chat tidy before anyone sees it.
--
-- chats.use registers middleware. It is called for every new message BEFORE the message is shown
-- anywhere (the Dashboard, the pop-out, your alerts, other plugins) and decides what happens:
-- return false, "reason" hide the message (the reason shows in this plugin's console)
-- return { text = "…" } show it, with different text
-- return nothing let it through
-- Keep it quick: a middleware that takes too long is skipped.
local rules = storage.get("rules", { links = true, repeats = true, caps = true })
local caught = { links = 0, repeats = 0, caps = 0 }
local last_said = {} -- channel id -> { text, ts }
local changed = false -- the counts moved: redraw on the next tick
-- The streamer, moderators and members are never filtered, and neither are Super Chats and the like.
local function exempt(chat)
local who = chat.author
return chat.kind ~= "text" or who.is_owner or who.is_mod or who.is_member
end
local function has_link(text)
text = text:lower()
return text:find("https?://") ~= nil or text:find("www%.") ~= nil
end
local function shouting(text)
local letters, upper = 0, 0
for c in text:gmatch("%a") do
letters = letters + 1
if c:match("%u") then upper = upper + 1 end
end
return letters >= 12 and upper / letters > 0.7
end
chats.use(function(chat)
if exempt(chat) then return end
local text = chat.text
if rules.links and has_link(text) then
caught.links, changed = caught.links + 1, true
return false, "link from someone who isn't a member"
end
if rules.repeats then
local before = last_said[chat.author.id]
last_said[chat.author.id] = { text = text, ts = chat.ts }
if before and before.text == text and chat.ts - before.ts < 30000 then
caught.repeats, changed = caught.repeats + 1, true
return false, "same message twice in 30 seconds"
end
end
if rules.caps and shouting(text) then
caught.caps, changed = caught.caps + 1, true
return { text = text:lower() }
end
end)
local section = dashboard.section({ id = "chat-filter", title = "Chat filter" })
local RULES = {
{ key = "links", label = "Hide links from non-members" },
{ key = "repeats", label = "Hide the same message sent twice in a row" },
{ key = "caps", label = "Turn SHOUTING into normal text" },
}
local function draw()
local children = { gap = 8 }
for _, rule in ipairs(RULES) do
children[#children + 1] = ui.row({
align = "center",
gap = 8,
ui.switch({
label = rule.label,
checked = rules[rule.key],
on_change = function(ev)
rules[rule.key] = ev.value == true
storage.set("rules", rules)
draw()
end,
}),
ui.spacer(),
ui.badge({ text = tostring(caught[rule.key]), tone = caught[rule.key] > 0 and "warning" or "neutral" }),
})
end
section:render(ui.column(children))
end
draw()
-- Middleware must stay fast, so it only counts; this timer does the drawing.
timer.every(1000, function()
if changed then
changed = false
draw()
end
end)What it shows
- Middleware with
chats.use: block a message (return false, reason), change it (return { text = … }) or leave it alone. - Telling people apart:
chat.author.is_owner,is_mod,is_member, andchat.kind(Super Chats are never filtered here). - Doing almost nothing inside middleware (only counting) and drawing from a timer, so it stays fast.
- Several switches built in a loop and remembered with
storage.
Try it
- Send
check https://example.comfrom a non-member in the Test environment: it never appears in the Dashboard's chat, and the plugin's console says why. - Send the same line twice, and send
THIS IS SO COOL EVERYONE: the second is hidden, the third arrives in lower case. - Turn a rule off with its switch and try again.
Chat stats
A Dashboard box with live numbers about the last five minutes of chat: messages, messages per minute, who is chatting and how many of them are members.
Needs chat.read, ui.dashboard.
-- Chat stats: live numbers about the last few minutes of chat, in a box on the Dashboard.
--
-- Nothing here reacts to single messages. A timer redraws the box every couple of seconds from
-- what chats.count and chats.authors report, which is cheap and keeps "5s ago" fresh.
local WINDOW = 5 * 60 -- seconds of chat to look at
local section = dashboard.section({ id = "chat-stats", title = "Chat stats" })
-- a big number with a caption under it
local function stat(value, caption)
return ui.column({
ui.heading(tostring(value)),
ui.text({ text = caption, muted = true, size = "sm" }),
})
end
local function draw()
local messages = chats.count({ since = WINDOW })
local people = chats.authors({ since = WINDOW }) -- most active first
local members = 0
for _, person in ipairs(people) do
if person.is_member then members = members + 1 end
end
local rows = {}
for i = 1, math.min(5, #people) do
local person = people[i]
rows[#rows + 1] = { i, person.name, person.count, util.ago(person.last_ts) }
end
section:render(ui.column({
gap = 12,
ui.row({
gap = 24,
wrap = true,
stat(messages, "messages in 5 minutes"),
stat(string.format("%.1f", messages / (WINDOW / 60)), "per minute"),
stat(#people, "people chatting"),
}),
-- `false` in a list of children means "nothing here"
#people > 0 and ui.progress({
label = string.format("%d of %d chatters are members", members, #people),
value = members / #people,
tone = "accent",
}) or false,
ui.table({
columns = { "#", "Chatter", "Messages", "Last message" },
rows = rows,
empty = "Nobody has chatted yet.",
}),
}))
end
draw()
timer.every(2000, draw)What it shows
- A Dashboard section that is redrawn from a timer (
dashboard.section,section:render,timer.every). - Reading chat without reacting to each message:
chats.countandchats.authorswithsince. - Layout and data elements:
ui.row,ui.column,ui.heading,ui.text,ui.progress,ui.table. falseas a child, for "draw this only when it makes sense".
Try it
- Turn on Developer mode, make a plugin from Plugins → New plugin → Blank, and paste this over
main.luaandplugin.json. - Open the plugin's Test environment and send some make-believe chat, or follow a real stream on the Dashboard.
- Watch the Chat stats box fill in on the Dashboard.
Clip that
Save OBS's replay buffer the moment something happens: say "clip that", press Ctrl+Shift+C (even in a game) or tap a key on your Stream Deck. Counts your clips on the Dashboard.
Needs obs, hotkeys, ui.app, ui.notify, ui.dashboard, storage.
-- Clip that: three ways to save OBS's replay buffer without leaving your game.
-- Ctrl+Shift+C a global hotkey (works while the game has the focus)
-- the "Clip that" button on your Stream Deck (ForgeCast's Stream Deck plugin)
-- saying "clip that" only if you allow the microphone (an optional permission)
-- Turn on the replay buffer in OBS first (Settings, Output, Replay Buffer).
local clips = storage.get("clips") or 0
local busy = false
local section = dashboard.section({ id = "clips", title = "Clip that" })
local clip
local function draw()
section:render(ui.column({
ui.text(clips == 1 and "1 clip saved" or (clips .. " clips saved")),
ui.text("Ctrl+Shift+C, your Stream Deck, or say \"clip that\".", { muted = true }),
}))
-- the label shows the count on the key
deck.button({ id = "clip", label = "Clip that (" .. clips .. ")", color = "#ff0044", on_press = function() clip("your Stream Deck") end })
end
-- saving takes a moment, and two presses in a row would save the same seconds twice
clip = function(how)
if busy then return end
busy = true
obs.save_replay(function(ok, why)
busy = false
if not ok then
ui.notify({ title = "Couldn't clip", message = why, level = "error" })
return
end
clips = clips + 1
storage.set("clips", clips)
ui.notify({ title = "Clipped!", message = "Saved by " .. how .. ".", level = "success" })
draw()
end)
end
hotkeys.on("Ctrl+Shift+KeyC", function() clip("the hotkey") end)
local function listen()
speech.listen({ "clip that", "clip it" }, function() clip("your voice") end)
end
if plugin.has("microphone") then
listen()
else
-- asked once; the streamer can say no and still use the hotkey and the Stream Deck
plugin.request("microphone", "To save a clip when you say \"clip that\".", function(allowed)
if allowed then listen() end
end)
end
draw()What it shows
- A global hotkey with
hotkeys.on("Ctrl+Shift+KeyC", fn): it works while a game has the focus. - A button on the Stream Deck with
deck.button, whose label shows a count (call it again to change it). - Voice commands with
speech.listen, behind an optional permission:plugin.haschecks it,plugin.requestasks for it once, and the plugin still works without it. - Saving OBS's replay buffer with
obs.save_replay, and a guard so two presses don't save the same seconds twice.
Try it
- In OBS, turn on the replay buffer (Settings, Output, Replay Buffer) and start it.
- Press Ctrl+Shift+C: a notification says the clip was saved, and the Dashboard counts it.
- With a Stream Deck: install ForgeCast's Stream Deck plugin (Devices in ForgeCast), put a ForgeCast button on a key and pick Clip that.
- Allow the microphone when asked, and say "clip that".
Giveaway
Viewers type !enter to join. Draw a winner from the people who did, as many times as you like, without ever picking the same person twice.
Needs chat.read, ui.dashboard, ui.modal, ui.notify, storage.
-- Giveaway: viewers type !enter, you press a button, a winner appears.
--
-- Entries are kept with storage, so closing ForgeCast in the middle of a stream loses nobody.
-- Someone who has already won is left out of the next draws until you press "Reset".
local KEYWORD = "!enter"
local section = dashboard.section({ id = "giveaway", title = "Giveaway" })
local state = storage.get("state", { open = true, entrants = {}, winners = {} })
-- entrants: channel id -> name winners: channel id -> true
local draw
local function save()
storage.set("state", state)
end
local function count(t)
local n = 0
for _ in pairs(t) do n = n + 1 end
return n
end
-- the people who entered and haven't won yet
local function pool()
local list = {}
for id, name in pairs(state.entrants) do
if not state.winners[id] then list[#list + 1] = { id = id, name = name } end
end
return list
end
chats.on(Event.NewChat, function(chat)
if not state.open then return end
if util.trim(chat.text):lower() ~= KEYWORD then return end
if state.entrants[chat.author.id] then return end -- one entry each
state.entrants[chat.author.id] = chat.author.name
save()
draw()
end)
local function draw_winner()
local candidates = pool()
if #candidates == 0 then
ui.notify({ title = "Nobody to draw", message = "Everyone who entered has already won.", level = "warning" })
return
end
local winner = util.pick(candidates)
state.winners[winner.id] = true
save()
draw()
ui.modal({
title = "And the winner is…",
body = ui.column({
align = "center",
gap = 6,
ui.heading(winner.name),
ui.text({ text = (#candidates - 1) .. " still in the draw", muted = true }),
}),
buttons = {
{ id = "close", label = "Close" },
{ id = "again", label = "Draw again", primary = true },
},
on_result = function(result)
if result.button == "again" then draw_winner() end
end,
})
end
draw = function()
local entered, left = count(state.entrants), #pool()
section:render(ui.column({
gap = 10,
ui.switch({
label = "Accepting entries (" .. KEYWORD .. ")",
checked = state.open,
on_change = function(ev)
state.open = ev.value == true
save()
draw()
end,
}),
ui.text({ text = entered .. " entered, " .. left .. " still in the draw", muted = true }),
ui.row({
gap = 8,
ui.button({ label = "Draw a winner", icon = "dice", variant = "primary", disabled = left == 0, on_click = draw_winner }),
ui.button({
label = "Reset",
variant = "ghost",
icon = "refresh",
disabled = entered == 0,
on_click = function()
ui.confirm("Forget everyone who entered?", function(yes)
if not yes then return end
state.entrants, state.winners = {}, {}
save()
draw()
end, { yes = "Reset", danger = true })
end,
}),
}),
}))
end
draw()What it shows
- Listening for one chat command and counting each person once (
chat.author.id). - Keeping state across restarts (
storage) and drawing fairly (util.pick). - A dialog with your own buttons, and a handler that runs when one is pressed (
ui.modalwithon_result). - Asking before something destructive (
ui.confirm) and warning when there is nothing to do (ui.notify). - A switch with
on_change, and a button that disables itself (disabled = …).
Try it
- Tell chat to type
!enter. - Press Draw a winner. Draw again in the dialog picks someone new.
- Turn the switch off to stop taking entries, and Reset when the giveaway is over.
Go-live webhook
Posts a message to a Discord webhook when your stream goes live, and tells you when the audience passes 50, 100 and 250 viewers.
Needs stream.read, network, ui.notify, ui.dashboard, storage.
-- Go-live webhook: tell your Discord when the stream starts, and cheer at viewer milestones.
--
-- Paste a Discord webhook address into the plugin's settings (Discord: channel settings ->
-- Integrations -> Webhooks -> New Webhook -> Copy Webhook URL). It is kept with storage, on this
-- computer only. http.post is asynchronous: the function you pass is called when the answer
-- arrives, and the rest of the plugin keeps running meanwhile.
--
-- Streams from the Test environment are ignored, so trying the plugin never posts to your Discord.
local MILESTONES = { 50, 100, 250 }
local config = storage.get("config", { webhook = "" })
local announced = storage.get("announced", {}) -- the last streams already announced, so a restart mid-stream doesn't post again
local settings = plugin.settings({ title = "Go-live webhook" })
local reached = {} -- video id -> the highest milestone already announced
local draw
-- a stream's page address, from its video id
local function stream_link(id)
return "https://www.youtube.com/watch?v=" .. id
end
-- Sends a message to the webhook. Returns false when there is no webhook to send it to.
local function post(text)
if config.webhook == "" then return false end
http.post(config.webhook, { content = text }, function(res)
if res.ok then
log.info("webhook delivered")
else
ui.notify({ title = "The webhook didn't go through", message = res.error or ("Discord answered " .. res.status), level = "error" })
end
end)
return true
end
local function remember(id)
announced[#announced + 1] = id
while #announced > 20 do table.remove(announced, 1) end
storage.set("announced", announced)
end
-- Event.StreamStart also fires when ForgeCast first finds a stream that is already live.
stream.on(Event.StreamStart, function(s)
if s.simulated then return end -- the Test environment's make-believe stream
local title = s.title or "a new stream"
ui.notify({ title = "You're live", message = title, level = "success" })
if util.contains(announced, s.id) then return end
if post("**" .. title .. "** is live now! " .. stream_link(s.id)) then remember(s.id) end
end)
stream.on(Event.StreamUpdate, function(s)
if s.simulated or not s.viewers then return end
local best = reached[s.id] or 0
for _, goal in ipairs(MILESTONES) do
if s.viewers >= goal and goal > best then
reached[s.id] = goal
ui.notify({ title = goal .. " viewers", message = (s.title or s.id) .. " just passed " .. goal .. " people watching.", level = "success" })
post(goal .. " people are watching right now!")
end
end
end)
local function save(ev)
local url = util.trim((ev.values or {}).webhook or "")
if url ~= "" and not util.starts_with(url, "https://") then
ui.notify({ message = "A webhook address starts with https://", level = "warning" })
return
end
config.webhook = url
storage.set("config", config)
ui.notify({ message = url == "" and "Webhook removed." or "Webhook saved.", level = "success" })
draw()
end
draw = function()
settings:render(ui.column({
gap = 10,
ui.input({ name = "webhook", label = "Discord webhook address", placeholder = "https://discord.com/api/webhooks/…", value = config.webhook }),
ui.row({
gap = 8,
ui.button({ label = "Save", variant = "primary", icon = "check", on_click = save }),
ui.button({
label = "Send a test message",
icon = "send",
disabled = config.webhook == "",
on_click = function() post("This is a test from ForgeCast.") end,
}),
}),
ui.text({ text = "The address is kept on this computer. Anyone who has it can post to that channel, so don't share it.", muted = true, size = "sm" }),
}))
end
draw()What it shows
- Stream events with
stream.on(Event.StreamStart,Event.StreamUpdate) and theStreamStatethey carry. - Calling the internet with
http.post(a table body is sent as JSON) and handling the answer in a callback (res.ok,res.status,res.error). - Keeping a secret out of the code: the address is typed into the settings panel and stored, not written in
main.lua. - Ignoring the Test environment's make-believe stream (
s.simulated), and not announcing the same stream twice (storageplusutil.contains).
Try it
- Make a webhook in a Discord channel you own and paste its address into the plugin's settings.
- Press Send a test message. A problem is reported in a notification.
- Follow a live stream (on the Dashboard, or by link in Developer mode): the channel gets a message once for that stream, and again at each viewer milestone.
Keyword alerts
A notification and a sound when chat mentions words you care about. The words are edited in the plugin's settings and kept between runs.
Needs chat.read, ui.notify, ui.dashboard, storage, sounds.
-- Keyword alerts: be told when chat says something you care about (a game, "giveaway", a question).
--
-- The words and the sound switch are edited in this plugin's settings panel (on its card in the
-- Plugins page) and kept with storage, so they are still there next time.
local SOUND = "Coin" -- the name of a sound in your Sound Library
local config = storage.get("config", { words = "question, help", sound = true })
local settings = plugin.settings({ title = "Keyword alerts" })
local keywords = {}
local saved = false
-- "Giveaway, question" -> { "giveaway", "question" }
local function parse(text)
local list = {}
for _, word in ipairs(util.split(text, ",")) do
word = util.trim(word):lower()
if word ~= "" then list[#list + 1] = word end
end
return list
end
keywords = parse(config.words)
-- the first watched word the text contains, or nil
local function find_keyword(text)
text = text:lower()
for _, word in ipairs(keywords) do
if text:find(word, 1, true) then return word end -- true = plain text, not a pattern
end
end
chats.on(Event.NewChat, function(chat)
if chat.author.is_owner then return end -- your own messages don't count
local word = find_keyword(chat.text)
if not word then return end
ui.notify({
title = chat.author.name .. " mentioned \"" .. word .. "\"",
message = chat.text,
level = "info",
duration = 8000,
})
if config.sound then sounds.play(SOUND) end
end)
local draw
local function save(ev)
local values = ev.values or {}
config.words = values.words or config.words
config.sound = values.sound == true
storage.set("config", config)
keywords = parse(config.words)
saved = true
draw()
end
draw = function()
settings:render(ui.column({
gap = 10,
ui.input({
name = "words",
label = "Words to watch for, separated by commas",
placeholder = "giveaway, question, my game",
value = config.words,
}),
ui.switch({ name = "sound", label = "Play a sound", checked = config.sound }),
ui.row({
gap = 8,
align = "center",
ui.button({ label = "Save", icon = "check", variant = "primary", on_click = save }),
saved and ui.badge({ text = "Saved", tone = "success" }) or false,
}),
ui.text({ text = #keywords .. " word(s) watched", muted = true, size = "sm" }),
}))
end
draw()What it shows
- Reacting to chat (
chats.on(Event.NewChat, …)) and showing a notification (ui.notify) with a sound (sounds.play). - A settings panel on the plugin's own card (
plugin.settings) built fromui.input,ui.switchandui.button. - How a button reads what was typed: a named input shows up in
ev.valuesfor every handler in the same interface. - Keeping settings between runs with
storage.getandstorage.set.
Try it
- Create the plugin, approve its permissions, and open its card in the Plugins page: the settings panel is on it.
- Change the words, press Save, then send a matching message from the Test environment.
- Get a sound under Sound Library → Find sounds and set
SOUNDto its name (or turn the sound switch off). Until a sound with that name exists, ForgeCast says so in a message instead of playing it.
Member goal
A progress bar towards a number of new members (gifted memberships count too). Celebrates with a notification, a sound and an on-stream alert when the goal is reached.
Needs chat.read, ui.dashboard, ui.notify, sounds, alerts, storage.
-- Member goal: a progress bar towards "N new members today".
--
-- A new member counts 1, a gift counts as many memberships as were gifted. When the goal is
-- reached, the plugin celebrates once: a notification, a sound, and one of YOUR alerts on stream
-- (alerts.show plays your alert design as if the event had happened).
local SOUND = "Coin"
local state = storage.get("goal", { goal = 10, count = 0, reached = false })
local section = dashboard.section({ id = "member-goal", title = "Member goal" })
local draw
local function save()
storage.set("goal", state)
end
local function add(n, who)
state.count = state.count + n
if state.count >= state.goal and not state.reached then
state.reached = true
ui.notify({ title = "Goal reached!", message = state.count .. " new members. Thank you, " .. who .. " and everyone before.", level = "success", duration = 0 })
sounds.play(SOUND)
alerts.show({ type = "member.milestone", name = who, message = "We hit " .. state.goal .. " new members!" })
end
save()
draw()
end
chats.on(Event.Member, function(chat)
add(1, chat.author.name)
end)
chats.on(Event.Gift, function(chat)
add(chat.count or 1, chat.author.name)
end)
draw = function()
section:render(ui.column({
gap = 10,
ui.row({
align = "center",
justify = "between",
ui.heading(state.count .. " / " .. state.goal),
state.reached and ui.badge({ text = "Goal reached", tone = "success" }) or false,
}),
ui.progress({ value = math.min(1, state.count / state.goal), tone = state.reached and "success" or "accent" }),
ui.row({
gap = 8,
align = "center",
ui.input({ name = "goal", label = "Goal", value = tostring(state.goal) }),
ui.button({
label = "Set goal",
on_click = function(ev)
local n = math.floor(tonumber((ev.values or {}).goal) or 0)
if n < 1 then
ui.notify({ message = "The goal has to be a whole number, like 25.", level = "warning" })
return
end
state.goal = n
state.reached = state.count >= n -- a higher goal can be celebrated again
save()
draw()
end,
}),
ui.button({
label = "Start over",
variant = "ghost",
icon = "refresh",
on_click = function()
state.count, state.reached = 0, false
save()
draw()
end,
}),
}),
}))
end
draw()What it shows
- Reacting to specific chat events:
Event.MemberandEvent.Gift(a gift'schat.countis how many memberships were given). - Showing one of your own alerts on stream with
alerts.show, next to a notification and a sound. - A progress bar (
ui.progress) and a form that checks what was typed before using it. - State that survives restarts (
storage), including a flag so the celebration only happens once.
Try it
- Set a small goal, like 3, and press Set goal.
- Send member and gift events from the Test environment, or wait for real ones.
- The bar fills, and the goal celebrates once. Set
SOUNDto the name of a sound from your Sound Library (Find sounds has thousands) to hear it.
Tip lights
Every tip lights up your room: Super Chats and tips from Ko-fi, Streamlabs and StreamElements flash your smart lights (gold for big ones) and fill one support goal for your overlays.
Needs chat.read, donations, lights, overlay, ui.notify.
-- Tip lights: every way people support you, in one place.
-- Super Chats, and tips from Ko-fi, Streamlabs and StreamElements (linked in Connections),
-- flash your smart lights (added in Devices): pink, or gold for a big one,
-- and add up in one goal your overlays can show:
-- {{goals.support.current}} / {{goals.support.target}} or a goal bar picking "support"
local BIG = 20 -- in your currency: a tip this big flashes gold
local TARGET = 100 -- the goal, in your currency
if not goals.get("support") then
goals.set("support", { current = 0, target = TARGET, title = "Support goal" })
end
local function supported(name, value, currency, source)
-- one goal, whatever the currency: everything in yours
local amount = money.convert(value, currency) or 0
goals.add("support", amount)
local big = amount >= BIG
lights.flash(big and "#ffd700" or "#ff4fa3", { times = big and 4 or 2 })
if big then
ui.notify({ title = name .. " sent " .. money.format(amount), message = "Thank you! (" .. source .. ")", level = "success" })
end
end
chats.on(Event.SuperChat, function(m)
if m.amount and not m.backlog then
supported(m.author.name, m.amount.value, m.amount.currency, "Super Chat")
end
end)
donations.on("tip", function(t)
if t.amount then
supported(t.name, t.amount.value, t.amount.currency, t.source)
end
end)What it shows
- Hearing tips from outside YouTube with
donations.on("tip", fn), next to Super Chats fromchats.on(Event.SuperChat, fn). - One goal for every currency:
money.convertturns each amount into the streamer's, andgoals.addfills a goal the overlays already know ({{goals.support.current}}). - Flashing smart lights with
lights.flash, which puts each light back the way it was afterwards. - Skipping the chat that was already there when the stream was opened (
m.backlog).
Try it
- Add a light in Devices (Elgato, Philips Hue, Nanoleaf or Govee).
- Link Ko-fi, Streamlabs or StreamElements in Connections (Super Chats work without it).
- In an overlay, add a goal bar on the goal support, or a text layer with
{{goals.support.current}} / {{goals.support.target}}. - In the plugin's test environment, send a Super Chat: the lights flash and the goal goes up.

