Data, timers and the internet
Keep settings between runs, run code later, call web APIs, play sounds, trigger alerts and talk to other plugins.
Storage
storage (needs storage) keeps data on this computer, per plugin, between runs. It survives reloads, restarts and updates of ForgeCast, and no other plugin can read it.
local settings = storage.get("settings", { volume = 5, words = {} }) -- the second argument is the default
settings.volume = 8
storage.set("settings", settings) -- saves it
storage.delete("old-key") -- true if it was there
print(table.concat(storage.keys(), ", ")) -- the names of everything saved, in order
storage.clear() -- forget everything- A value can be a string, number, boolean, or a table made of those (nested as deep as you like). Functions can't be saved.
storage.set(key, nil)removes the key. - A key is 1 to 200 characters. A plugin can keep up to 1 MB and 5,000 keys. Going over fails with a message, and leaves what was there untouched.
storage.getreturns a fresh copy each time: changing the table you got changes nothing until you callstorage.setwith it.- Tables that look like lists (keys 1, 2, 3…) come back as lists; any other table comes back with its keys. An empty table can come back as either, so don't rely on telling
{}from[]. - Nothing is encrypted. Don't store secrets that matter. (A webhook address, as in the example, is fine to keep on the person's own computer.)
When the plugin is turned off or reloaded, ForgeCast calls the function you register with plugin.on_unload(fn), so you can save a last time:
plugin.on_unload(function() storage.set("last-seen", time.now()) end)Timers
local id = timer.every(1000, function() print("a second passed") end) -- every second (50 ms at the least)
timer.after(5000, function() timer.cancel(id) end) -- once, in five secondstimer.after(ms, fn) and timer.every(ms, fn) return an id; timer.cancel(id) stops either. A timer that errors writes the error to the console and keeps going, and the plugin's other timers are not affected. A plugin may have 200 timers and 200 event handlers.
Timers are how you draw regularly, wait before doing something, or do work in small pieces. A callback has 250 ms; never wait in a loop (while or busy-waiting), because that is also stopped.
Time, JSON, logging and helpers
local now = time.now() -- milliseconds since 1970 (compare with chat.ts)
local text = json.encode({ name = "ann", n = 3 }) -- '{"n":3,"name":"ann"}' (json.encode(value, true) is indented)
local value, err = json.decode('{"a": 1}') -- nil and a message when the text isn't JSON
print(util.clock()) -- "12:30:05"
print(util.ago(chat.ts)) -- "5s ago", "3m ago", "2h ago"log.debug, log.info, log.warn and log.error write to the plugin's console (print is log.info). Several arguments are joined with spaces, and a table is printed with what is in it (a few levels deep). log.error doesn't stop anything.
util has the small things Lua leaves out:
| Function | Does |
|---|---|
util.trim(s) | The text without spaces at either end. |
util.starts_with(s, prefix), util.ends_with(s, suffix) | What they say. |
util.split(s, sep) | Split on a plain separator (default ","). |
util.contains(list, value), util.index_of(list, value) | Is it in the list, and where. |
util.remove_value(list, value) | Remove the first match; true if it was there. |
util.shuffle(list), util.pick(list) | Shuffle in place; a random item (nil for an empty list). |
util.keys(t) | The keys of a table, sorted. |
util.clock(ms), util.ago(ms) | Text for a time. |
Lua 5.4 itself is all there too: string, table, math, utf8, coroutine, and os.time, os.date, os.clock and os.difftime. require("name") loads another .lua file in the plugin's folder. See the sandbox for what is left out.
The internet
http (needs network) calls websites and APIs over https. It never blocks: you give it a function, and ForgeCast calls that function when the answer arrives.
http.get("https://api.example.com/status", function(res)
if not res.ok then
log.warn("couldn't read the status:", res.error or res.status)
return
end
print("status is", res.json.state) -- res.json is the body parsed, when it was JSON
end)
http.post("https://hooks.example.com/notify", { text = "We're live!" }, function(res)
print("sent:", res.ok, res.status) -- a table body is sent as JSON
end)
http.request({
url = "https://api.example.com/things/7",
method = "PUT", -- GET, POST, PUT, PATCH or DELETE
headers = { Authorization = "Bearer " .. token },
json = { name = "new name" }, -- or body = "plain text"
timeout = 5, -- seconds, 1 to 15 (default 10)
}, function(res) print(res.status) end)The answer res has:
| Field | Meaning |
|---|---|
ok | The request worked and the status was 2xx. |
status | The HTTP status (0 when there was no answer). |
body | The body as text. |
json | The body parsed, when it is JSON. |
headers | The response headers, with lower-case names. |
error | Why it failed (no answer, blocked…). |
What the plugin sandbox enforces on every request:
- https only, and public sites only. Addresses on this computer or the local network (
localhost,192.168.x.x,.local, and so on) and addresses with a user name or password are refused, so a plugin can't poke at your network. - Up to 8 requests waiting at once and 30 a minute. A request body can be 1 MB and an answer 2 MB.
- Some headers (
Host,Content-Length,Connection,Transfer-Encoding) are set by ForgeCast, not by the plugin. - A refused request raises an error that says why, before anything is sent. A request that was sent but failed (no connection, a timeout) arrives in the callback with
ok = falseand anerror.
The network permission can send chat anywhere
A plugin with network can send anything it can see (chat, names, viewer numbers) to the sites it calls. People are warned when they approve it. Only ask for it when you need to, and only call addresses you control or trust.
Sounds and alerts
sounds.play("Coin") -- a sound from your Sound Library, by name (needs "sounds")
alerts.show({ -- one of YOUR alerts, as if it had just happened (needs "alerts")
type = "member.new", -- "superchat" (default), "supersticker", "member.new",
-- "member.gift", "member.milestone" or "subscriber.new"
name = "Ann",
message = "Welcome!",
-- for money: amount = 5, currency = "USD", and detail = "…" for the second line
})alerts.show uses the alert you designed under Alerts, so it is the best way to make a plugin celebrate on stream. Alerts raised this way look like real ones: use it for things that really happened.
Links, and the app
app.open_url("https://example.com/docs") -- opens in the browser; https only (needs "app.open_url")
local info = app.info() -- no permission needed
print(info.version, info.platform, info.api)
print(plugin.id, plugin.name, plugin.version, plugin.dir)An interface can also contain ui.link("label", "https://…"), which opens in the browser when clicked and needs no permission.
Talking to other plugins
bus (needs bus) sends messages between your plugins:
-- in the first plugin
bus.emit("goal.reached", { goal = 100 })
-- in another plugin
bus.on("goal.reached", function(msg)
print(msg.from, msg.data.goal) -- "member-goal", 100
end)A message has a name (1 to 60 letters, digits or . _ : -) and any JSON-like data up to 64 KB. The handler gets { from = "plugin-id", data = … }. bus.on returns an id for bus.off. Both plugins need the bus permission, and neither is told if nobody is listening.
Several files
Keep the code in pieces once it grows:
-- helpers.lua
local M = {}
function M.shout(text) return text:upper() .. "!" end
return M-- main.lua
local helpers = require("helpers") -- "lib.format" would be lib/format.lua
print(helpers.shout("hello"))require reads .lua files inside the plugin's own folder only, and each file runs once (the second require returns the same value). Files in the folder trigger a reload when you save them in Developer mode.

