Skip to content
ForgeCastDocs

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, and overlay.clear() takes them away.
  • A countdown with timer.every, and putting things back when the plugin is turned off (plugin.on_unload).

Try it

  1. Connect ForgeCast to OBS, and make a scene for breaks (called "BRB", or pick yours in the list).
  2. In your BRB overlay, add a text layer with {{plugin.brb-break.left}}, and one with {{plugin.brb-break.message}}.
  3. 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) and history.streams{ limit = 1 } (the latest one).
  • Putting the answer in your overlays with overlay.set: {{plugin.chat-cohost.answer}}, .asker and .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

  1. Turn on AI in ForgeCast's settings (each answer costs AI credits), then switch on Answer !ask.
  2. Add a text layer with {{plugin.chat-cohost.asker}}: {{plugin.chat-cohost.question}} and one with {{plugin.chat-cohost.answer}} to an overlay.
  3. 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, and chat.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

  1. Send check https://example.com from a non-member in the Test environment: it never appears in the Dashboard's chat, and the plugin's console says why.
  2. Send the same line twice, and send THIS IS SO COOL EVERYONE: the second is hidden, the third arrives in lower case.
  3. 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.count and chats.authors with since.
  • Layout and data elements: ui.row, ui.column, ui.heading, ui.text, ui.progress, ui.table.
  • false as a child, for "draw this only when it makes sense".

Try it

  1. Turn on Developer mode, make a plugin from Plugins → New plugin → Blank, and paste this over main.lua and plugin.json.
  2. Open the plugin's Test environment and send some make-believe chat, or follow a real stream on the Dashboard.
  3. 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.has checks it, plugin.request asks 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

  1. In OBS, turn on the replay buffer (Settings, Output, Replay Buffer) and start it.
  2. Press Ctrl+Shift+C: a notification says the clip was saved, and the Dashboard counts it.
  3. With a Stream Deck: install ForgeCast's Stream Deck plugin (Devices in ForgeCast), put a ForgeCast button on a key and pick Clip that.
  4. 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.modal with on_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

  1. Tell chat to type !enter.
  2. Press Draw a winner. Draw again in the dialog picks someone new.
  3. 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 the StreamState they 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 (storage plus util.contains).

Try it

  1. Make a webhook in a Discord channel you own and paste its address into the plugin's settings.
  2. Press Send a test message. A problem is reported in a notification.
  3. 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 from ui.input, ui.switch and ui.button.
  • How a button reads what was typed: a named input shows up in ev.values for every handler in the same interface.
  • Keeping settings between runs with storage.get and storage.set.

Try it

  1. Create the plugin, approve its permissions, and open its card in the Plugins page: the settings panel is on it.
  2. Change the words, press Save, then send a matching message from the Test environment.
  3. Get a sound under Sound Library → Find sounds and set SOUND to 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.Member and Event.Gift (a gift's chat.count is 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

  1. Set a small goal, like 3, and press Set goal.
  2. Send member and gift events from the Test environment, or wait for real ones.
  3. The bar fills, and the goal celebrates once. Set SOUND to 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 from chats.on(Event.SuperChat, fn).
  • One goal for every currency: money.convert turns each amount into the streamer's, and goals.add fills 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

  1. Add a light in Devices (Elgato, Philips Hue, Nanoleaf or Govee).
  2. Link Ko-fi, Streamlabs or StreamElements in Connections (Super Chats work without it).
  3. In an overlay, add a goal bar on the goal support, or a text layer with {{goals.support.current}} / {{goals.support.target}}.
  4. In the plugin's test environment, send a Super Chat: the lights flash and the goal goes up.

On this page