Overview
Scripts are small Lua programs that extend your server. They run in an isolated Lua 5.5 sandbox process, completely separated from the bot. A script never touches the database, the filesystem or other servers. Every capability goes through a whitelisted host API that yields back to the bot over IPC.
No script loaded, no process exists.
bot:* and msg:* calls are capability-checked by the bot, not by Lua.
AutoMod, welcome and tickets always run. Scripts decorate, they do not replace.
A crashing script is unloaded or quarantined, the bot keeps running.
There is no in-browser editor. Write .lua in a real editor and upload it on the Scripts page of your server.
Getting started
- 1
Write a .lua file locally
VS Code, Neovim or any editor. - 2
Check it locally (optional)
script_runner --check file.luaprintscheck: ok. - 3
Upload it
Open your server's Scripts page and paste the code or upload the .lua file (up to 64 KiB). - 4
Dry-run
Validates size, encoding, bytecode and forbidden APIs without executing anything. - 5
Activate
Wait for staff approval of the version, then press Activate. The bot loads it within seconds. - 6
Set a log channel
Receivebot:logoutput and error notifications there.
Local CLI
The runner is the same binary the bot uses.
- script_runner --check file.lua
Static syntax/forbidden-API check without running. Prints 'check: ok'.
- script_runner --eval 'code'
Run a snippet in the sandbox locally. Errors print kind + message.
- script_runner --self-test
Runs the sandbox, protocol and socket self-tests.
Lifecycle & review
Scripts are versioned. A version is frozen when you submit it and identified by its SHA-256. Only approved versions can be activated, and only active versions are loaded by the bot.
- 1
Write
Create the .lua file locally in VS Code, Neovim or any editor. There is no in-browser editor by design. - 2
Dry-run / upload
Paste the code or upload the .lua file on the guild Scripts page (≤ 64 KiB, no binary). Dry-run validates size, encoding, bytecode and forbidden APIs without executing anything. - 3
Review
Every new script and every new version starts as pending. Staff approves or rejects the exact code revision before it can ever run. - 4
Activate
You activate an approved version. Activation of a pending or rejected version is refused. Only active versions are loaded by the bot. - 5
Run
The bot loads the version (code + SHA-256, verified by the runner) into an isolated Lua 5.5 sandbox. Events fan out only to handlers registered with bot:on. - 6
Update / rollback
Upload a new version (pending again) or activate an older approved version to roll back. KV storage stays. - 7
Disable / delete
Disable stops loading immediately, 'Disable all' hits every active script of the guild. Delete removes the script, its versions, KV and errors.
Script status
Shown as a badge on every script.
- draft
Created, no active version yet.
- pending
Waiting for something (e.g. first approval) — never running as draft.
- active
An approved version is activated and loaded by the bot.
- rejected
Staff rejected; the script will not load.
- disabled
Manually disabled (you, staff, 'Disable all', or quota removal) — unloaded.
- quarantined
Automatic shutdown after limit abuse (rate flood, queue flood, repeated faults). Unloaded and needs staff attention.
Version review
Every code revision goes through staff review.
- pending
Staff has not decided yet. Cannot be activated.
- approved
May be activated. New uploads of installed store scripts skip the queue (the listing version was already approved).
- rejected
Staff declined this code revision. Upload a new version instead.
Quota: 2 active scripts per server free, 10 with premium. If the quota shrinks, the oldest active scripts above it are disabled automatically and you are notified via the script log channel.
Handlers & events
Register handlers with bot:on(event, fn). The script reports its registered events at load, and only those events are delivered to it. Unknown event names fail the load, and a script may register at most 8 handlers per event.
bot:on("message_create", function(msg)
if msg.content == "!ping" then
msg:reply("pong")
end
end)Message-like events (message_*, automod_check) pass a message object with methods, see Message object.
Host calls yield: while a handler waits for bot:kv_get and similar calls, no second event runs on the same script. New events wait in a queue of 32, overflow drops the oldest event, and sustained flooding quarantines the script.
guild_id is always injected into the payload. Events are only delivered to handlers registered with bot:on. Unknown event names fail the load with a policy error. There is no voice_state event.
Event payloads
Every payload carries guild_id. Fields are lowercase snake_case tables.
- message_create
id, guild_id, channel_id, author_id, author, content, attachments, referenced_id
Fires before tags/ autoresponder. Attachments: up to 8 entries { id, filename, size }. referenced_id is 0 when there is no reply reference.
- message_update
same as message_create
- message_delete
channel_id, id
- message_delete_bulk
channel_id, ids
ids is an array of deleted message ids.
- guild_member_add
user_id
Bot accounts are skipped.
- guild_member_remove
user_id
- guild_member_update
user_id
- reaction_add
channel_id, id, author_id, user_id, emoji, emoji_id
Bot reactions are skipped. emoji is the name, emoji_id is 0 for unicode emoji.
- reaction_remove
channel_id, id, author_id, user_id, emoji, emoji_id
- channel_create
id, name, type
- channel_update
id, name, type
- channel_delete
id, name
- role_create
id, name
- role_update
id, name
- role_delete
id, name
- thread_create
id, name
- thread_update
id, name
- thread_delete
id
- invite_create
code, uses
- invite_delete
code
- guild_ban_add
user_id
- guild_ban_remove
user_id
- welcome_before
user_id, username
Fires before the native welcome message. Non-bot joins only.
- goodbye_before
user_id
Fires before the native goodbye message.
- ticket_created
ticket_id, user_id, channel_id
- automod_check
content, channel_id, author_id, id
Fires before native AutoMod. msg:escalate() deletes the in-flight message; native deny always wins; msg:allow() is a no-op.
- webhooks_update
channel_id
- emoji_update
count, ids
ids capped at 8.
- tick
unix
Every 60 seconds, delivered only to scripts that registered bot:on("tick"). unix is unix time.
Host APIs
These calls yield to the bot over IPC. Rate limits per script: 30 host calls / 10 s, plus 5 Discord sends / 10 s (reply, channel_send, react, edit, delete, pin, escalate, http_get). Cache reads such as member_get and guild_get never hit Discord directly.
- bot:log(msg)
Writes to the script log channel and the error log. Counts against the 30-calls / 10 s limit.
- bot:kv_get(key)
Returns the stored JSON value or nil. Keys: 1–64 chars [A-Za-z0-9_-].
- bot:kv_set(key, value)
Stores up to 8 KiB JSON. 256 keys per script, 1 MiB per script (8 MiB premium), 8 MiB per guild.
- bot:kv_del(key)
Deletes one key.
- bot:kv_keys()
Returns up to 256 keys, sorted.
- bot:kv_incr(key, delta?)
Increments a stored JSON number, default delta 1, delta limited to ±1,000,000,000.
- bot:db_exec(sql, binds?)
Runs one write or schema statement (CREATE TABLE/INDEX, INSERT, UPDATE, DELETE, DROP) on the server script database. Optional binds: an array of nil, boolean, number or string, up to 8. Returns { changes, last_insert_rowid }. Quota, busy or limit problems return { error, kind } instead of raising; forbidden statements raise a policy error.
- bot:db_query(sql, binds?)
Runs one SELECT with optional binds and returns up to 50 rows, each a table keyed by column name. Always use LIMIT. On failure it returns { error, kind }, so check rows.error.
- bot:http_get(url)
HTTPS GET only. Host must be on the guild allowlist. Returns { status, body, truncated }; body is capped at 16 KiB. The wait does not count against the 100 ms Lua wall clock.
- bot:channel_send(channel_id, text, embed?)
Send to any channel of the own guild the bot can write to. Same content rules as msg:reply.
- bot:channel_get(channel_id)
Cache read: { id, name, type, parent_id }. Own guild only.
- bot:member_get(user_id)
Cache read: { id, nick, bot, roles (up to 20) }.
- bot:role_get(role_id)
Cache read: { id, name, position, color }.
- bot:guild_get()
Own guild cache: { id, name, owner_id, member_count }.
- bot:self()
The bot user: { id, username, bot }.
- bot:has_role(user_id, role_id)
Cache read, returns { has: true|false }.
- bot:channels()
Cache list of the guild's channels (max 50).
- bot:roles()
Cache list of the guild's roles (max 50).
- bot:now()
Unix time in seconds.
- bot:id()
This script's UUID. Does not yield, does not count against rate limits.
- bot:message_get(channel_id, message_id)
Cache read of the bot's message cache (no network fetch).
- bot:can_send(channel_id)
Returns { ok: true|false } whether the bot may send in that channel.
What bot:http_get accepts
- HTTPS only
http:// URLs are denied. No userinfo (user:pass@host).
- Server allowlist
The host must be on the server's HTTP allowlist (Scripts page, up to 32 hosts). You manage it yourself.
- No infrastructure hosts
IP literals, localhost, *.local and *.internal are always denied.
- Response
{ status, body, truncated }, body capped at 16 KiB. Timeout 3 s. Shares the 5 per 10 s Discord bucket.
Message object
Message events pass a msg table plus the payload fields. The methods act only on the in-flight event message, you cannot fetch and edit arbitrary messages.
Content rules everywhere: text 1 to 2000 bytes, at most one embed { title, description, color }, mentions resolve for the event author only, @everyone and @here are stripped. No files, no stickers.
- msg:reply(text, embed?)
Replies in the event channel. Mentions resolve for the event author only; @everyone / @here are stripped. Text 1–2000 bytes, max one embed.
- msg:react(emoji)
Adds a reaction on the event message.
- msg:unreact(emoji)
Removes the script's own reaction.
- msg:edit(text, embed?)
Edits the in-flight message. Same content rules as reply.
- msg:delete()
Deletes the in-flight message.
- msg:pin() / msg:unpin()
Pin/unpin the in-flight message.
- msg:escalate()
automod_check only: deletes the in-flight message (extra action, rate-limited). Native AutoMod decisions always win.
- msg:allow()
automod_check only: marks the message as allowed. This cannot undo a native deny — it is effectively a no-op marker.
Limits & quotas
All limits live in one config file the bot and the runner share. They are enforced at runtime, not just documented here. The numbers are the current defaults.
- Language
Lua 5.5, text chunks only (bytecode is rejected). No io/os.file/require/load/debug/coroutine.
- Compute
~100 ms wall clock per handler, 10,000,000 instructions (count hook).
- Memory
2 MB per script free, 8 MB premium.
- Code size
64 KiB UTF-8 .lua, no NUL bytes, no bytecode.
- Host calls
30 per 10 s per script (bot:log included).
- Discord sends
5 per 10 s per script (reply, channel_send, react/unreact, edit, delete, pin/unpin, escalate, http_get). 8 denies in 10 s → quarantine.
- Handlers
Max 8 per event per script. Unknown event names are rejected at load.
- Event queue
Depth 32 per script, drop-oldest on overflow. 8+ drops in 10 s → quarantine.
- HTTP
HTTPS + guild allowlist, timeout 3 s, body 16 KiB, no IP/localhost/internal hosts, no userinfo.
- KV
Keys 1–64 [A-Za-z0-9_-], values ≤ 8 KiB JSON, 256 keys/script, 1 MiB/script (8 MiB premium), 8 MiB/guild.
- Database
One SQLite file per server, shared by all its scripts: 1 MiB (8 MiB premium), one statement per call, SQL ≤ 2 KiB, ≤ 8 binds, ≤ 50 rows, 20 ms per statement, 64 tables and indexes. No ATTACH, PRAGMA, transactions, triggers, views or WITH. Every script on the server can read and delete every table.
- Active scripts
2 per guild free, 10 premium. Excess oldest-active scripts are disabled automatically.
- Versions
Unlimited count per script; every new version is pending until staff approves it.
- Mentions
Event author only. @everyone / @here stripped. No files, no stickers.
- Isolation
3 faults (timeout/memory/policy/runtime) → own isolated worker (max 4), else quarantine + unload.
- Error log
Last 24 h visible in the dashboard, entries purged after 7 days.
- Forbidden
No voice_state, no mod_* APIs (ban/kick/timeout), no cross-guild access, no database/filesystem, no monetization.
- AutoMod
Native AutoMod runs first and always wins over scripts.
Validation
Before a version is stored, the backend runs static checks. Dry-run does the same without saving. The sandbox itself is the second line: forbidden globals simply do not exist at runtime.
- Size / encoding
Non-empty, ≤ 64 KiB, UTF-8 without NUL bytes.
- Bytecode
Anything starting with the Lua bytecode signature is rejected.
- Forbidden APIs
Static scan rejects: load(, loadfile, dofile, require(, os.execute, io.open, debug., string.dump, loadstring, getfenv, setfenv, package., coroutine., ffi., jit.
- Runtime sandbox
Even if something slips through the static scan, the sandbox simply does not expose these globals — the call fails with a policy error.
Available built-ins: the standard Lua library without file access. string, table, math, tostring, tonumber, type, pairs, ipairs, os.time, os.clock, os.difftime and bot.
Errors & quarantine
Every handler error is recorded with a kind and shown on the script detail page (last 24 h). Entries are purged after 7 days.
- runtime
A Lua error in your handler (nil access, bad arguments, …). The message includes the Lua error text.
- timeout
The handler exceeded the 100 ms wall clock or an IPC call timed out (1 s).
- memory
The script exceeded its memory cap.
- policy
The script tried something not allowed: unknown event, too many handlers, disallowed API, oversized payload.
Repeated abuse escalates automatically: 3 faults (timeout, memory, policy, runtime) move the script to its own isolated worker. If misbehaviour continues, or no isolated slot is free (max 4), the script is quarantined: unloaded and marked in the dashboard. Rate floods (8 denied Discord sends or 8 queue drops in 10 s) quarantine immediately. You are notified via the script log channel or DM.
Ops side: the bot can be paused globally via a kill switch without a restart, and a scripts:updated signal reloads changed versions within seconds. Both are staff actions.
Store
The store lets you share scripts publicly and install scripts from other servers. Publishing and installing both create copies. Installing a listing creates an independent script in your server, it does not stay linked to the author's code.
- Publish
Requires an active script with an approved version, a description (10–512 chars) and 1–8 tags ([a-z0-9-], 1–24 chars). You must accept the store ToS once before the first publish.
- Install
Installing copies the listing into your guild as its own script (draft, pre-approved version, own name with suffix if taken). KV is not copied. The install counter on the listing increases.
- Unpublish
Removes the listing from the store. Existing copies stay and keep running; they simply receive no more updates.
- No money
Sales, paywalls, donation/crypto links and similar monetization in description or code are rejected at publish and can get the script banned.
- Reports
Anyone with guild access can report a listing with a reason. Staff sees an open-reports queue and closes reports after review.
- Limits
The store list shows up to 50 listings per query (search, tag, sort by installs or newest). The normal guild script quota applies to installs.
Examples
Select an example to see its code.