Zeny

Docs

Lua 5.5 · API v0.1

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.

One sandbox per script

No script loaded, no process exists.

Rate-limited host calls

bot:* and msg:* calls are capability-checked by the bot, not by Lua.

Native features win

AutoMod, welcome and tickets always run. Scripts decorate, they do not replace.

Errors stay contained

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. 1

    Write a .lua file locally

    VS Code, Neovim or any editor.
  2. 2

    Check it locally (optional)

    script_runner --check file.lua prints check: ok.
  3. 3

    Upload it

    Open your server's Scripts page and paste the code or upload the .lua file (up to 64 KiB).
  4. 4

    Dry-run

    Validates size, encoding, bytecode and forbidden APIs without executing anything.
  5. 5

    Activate

    Wait for staff approval of the version, then press Activate. The bot loads it within seconds.
  6. 6

    Set a log channel

    Receive bot:log output 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. 1

    Write

    Create the .lua file locally in VS Code, Neovim or any editor. There is no in-browser editor by design.
  2. 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. 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. 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. 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. 6

    Update / rollback

    Upload a new version (pending again) or activate an older approved version to roll back. KV storage stays.
  7. 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.

luaLua
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.