Mallard

Subsystems & limits

The pieces of the script API that aren't a trigger, a ctx property, or a function on their own: outbound HTTP, configuration and secrets, custom forms, media storage, caching, Twitch, how failures propagate, and the numeric budgets every tier enforces.

fetch()

The only network access scripts have; full signature on Script functions . HTTPS-only by default (HTTP is allowed only when the env record has fetch_allow_http set), standard ports only, GET/POST/PUT/PATCH/DELETE/HEAD only, private/internal addresses are blocked (SSRF-guarded), and responses are auto-decompressed. A 5s default timeout, up to 15s via options.timeoutMs, and a 1 MB response cap (post-decompression). Request bodies are capped at 64 KB.

The options parameter can be passed as a plain JavaScript object or a JSON-serialised string: { method, headers, body, timeoutMs }. The body can be a string or a JSON object. Allowed request headers are accept, content-type, authorization, user-agent, client-id, api-key, anthropic-version, and any x-* header; anything else is dropped. Any {{secret:NAME}} placeholder in the URL, a header value, or the body is substituted server-side with the real secret before the request is sent.

Two separate limits apply per guild: 30 requests/minute (a flat sliding window, same on every tier) and an hourly call budget that scales by tier (10/50/100/200 on Free/Plus/Pro/Ultra; see the tier table below), exposed live as ctx.fetchCallsRemaining so a data-dependent loop can size itself against what's left.

Environment variables

Guild-level configuration lives in a single key-value record with the key env, whose value is a JSON object edited as one blob on the Database page. Its keys are exposed to every script as ctx.env.* (e.g. {"mod_log_channel_id":"123"} becomes ctx.env.mod_log_channel_id), read-only during execution. Every value is a string; non-string JSON values are stringified when read.

Secrets

Sensitive values like API keys live in a separate secrets record that only guild administrators can view or edit; mods with script access cannot. Scripts never receive the real values: each key is exposed as an opaque placeholder token {{secret:NAME}} via ctx.secret.*, and the real value is substituted server-side only inside fetch() (in the URL, header values, or body). Use it directly, e.g. authorization: "Bearer " + ctx.secret.MyKey. A key is present only once an admin sets a non-empty value, so if (ctx.secret.MyKey) tests whether it's configured. Because a script only ever holds the placeholder, logging it, sending it in a message, or throwing it can never leak the real secret.

Key-value store & storage

Each guild has its own key-value store (getKV/setKV/deleteKV) with optional per-entry expiry, browsable and editable as JSON on the Database page. env and secrets are reserved system keys: getKV/setKV/deleteKV all fail closed on them (a script-visible error), so they can only be read through ctx.env/ctx.secret and edited on the Database page.

Writes persist immediately. A setKV call lands in the database the instant it returns, so a value it wrote survives even if the script errors or times out later in the same run (see Error handling ). Total persistent storage per guild is capped by tier (0.5/1/5/10 MB; see the table below) and is shared between key-value entries and uploaded media assets. A KV write or media upload that would push total storage past the cap throws.

Keep a single entry small, and split it before it grows. Only the per-guild total is capped, but a run has its own memory budget, and JSON.parse-ing one large value into objects costs many times the value's own size. Past roughly 512 KB a read-modify-write of one entry can start failing even though the write half still succeeds, so a list that only ever grows (moderation history, a log, a leaderboard) belongs under several keys rather than one. The seeded moderation scripts key notes by user (mod:notes:<userId>) for exactly this reason: a command then reads one member's history instead of the whole server's. The Database page flags any entry past that size.

Forms & submissions

Guilds can create public web forms from the dashboard's Forms tab, hosted at /forms/{guildId}/{formId}. Forms are suitable for staff applications, player reports, feedback, and ban appeals.

Each form configures an authentication requirement: Discord (verifies the submitter's Discord account), Twitch (verifies Twitch account), Discord or Twitch (allows either), or Anonymous (no login required). Forms support Short Text, Long Text, Dropdown, and Checkbox fields, each with optional header images or attached media.

Submissions are stored automatically in the guild's key-value store under the key forms:submissions:<formId> and can be browsed and exported directly from the dashboard's Forms tab. Submitting also fires the FormSubmitted trigger for enabled scripts, populating ctx.form with the submitter's verified identity, form metadata, and answers (accessible both as a dictionary via ctx.form.answers and as pre-formatted markdown via ctx.form.answersText).

Ban appeals are ordinary forms. Every guild includes a seeded "Ban Appeal" form. The legacy /appeal/{guildId} URL automatically redirects to this form, and the seeded appeal-notify script listens for submissions to post them to the server's configured appeals channel.

Media & asset uploads

The dashboard's Media tab provides asset hosting for images (PNG, JPEG, WebP, GIF) directly on Mallard. Uploaded media is served via the edge and can be used in form banners, field illustrations, script embeds, and announcements.

Uploaded media shares the guild's persistent storage budget with the key-value store. The breakdown of storage between KV entries and media files is shown on the dashboard's General page in the usage meter. Deleting unused media assets or KV entries frees up storage instantly. Media files are included when exporting or restoring guild backups.

Message cache

An in-memory cache of every message seen in the last 30 minutes, scoped per guild and per channel. It's used to recover best-effort deleted-message content on the MessageDelete trigger, and to read a message's previous content on MessageUpdate (Discord's edit event only carries the new version). getMessage() and getMessages() do not read it; they fetch live from Discord over REST (each counting as one rate-limited action), so they return complete, authoritative history rather than only what the bot recently saw. Being in-memory, a bot restart empties it, so a deleted/edited message right after a restart has no recoverable content.

Twitch

Link a Twitch channel on the General page to enable the TwitchStreamOnline, TwitchStreamOffline, and TwitchChannelUpdate triggers. Just entering the broadcaster's channel name is enough, no authorization needed. Those three dispatch immediately, fanned out to every guild following that broadcaster with a matching enabled script.

TwitchChatBatch additionally needs a chat-reader authorization from the streamer or one of their moderators. Chat is buffered per guild (up to 1,000 messages per window; once full, the oldest plain message is dropped to make room; redemptions and bit cheers are only evicted if literally everything buffered is one of those. The drop count is reported as ctx.twitch.messagesDropped) and flushed as one execution every ~10 minutes via ctx.twitch.messages, never per message, so it can't burn through the hourly execution budget. Nightbot's messages are filtered out before they reach a script. A channel-point text redemption arrives as a normal chat message with rewardId set, and a bit cheer with bits set, which is how redemption/cheer flows (e.g. a shoutout command) are scripted without needing broadcaster-only scopes.

Status chips (Pending / Active / Error / Reauthorize Required / Not Connected) on the General page reflect the current connection state; a revoked authorization or a reader losing mod status flips the status and is cleaned up automatically within about a minute.

Error handling & guild isolation

Actions run live, in call order, the moment a script calls them. There's no queue and no all-or-nothing rollback. A script that errors, times out, or exhausts a budget partway keeps every action, KV write, and fetch it already made, and all of it counts against the hourly budgets regardless of how the run ended. This is why work that must happen later (lifting a temp ban, a reminder) is logged as intent to the KV store and swept periodically by a Scheduled script, rather than the script trying to "wait."

A failed Discord action normally throws and ends the script with an Error status. The one exception is a target that simply isn't reachable: a user who doesn't accept DMs, a member who already left, a message already deleted. Those outcomes are outside a script's control, so sendDm, kick, timeout, unban, and deleteMessage return false instead of throwing, and the script keeps going (the REST call still counts against the hourly action budget). ban is deliberately excluded from this list. Discord allows banning a non-member fine, so a 404 there means a genuinely bad user ID. Everything else still throws: a bad snowflake, a missing permission, an exhausted budget, a target in another guild.

Every action a script takes is scoped to its own guild: channel-targeting functions validate the target actually belongs to the executing script's guild before acting, so a script cannot read or modify another server no matter what ID it's given.

Tiers & limits

Every tier includes the entire scripting engine: every trigger, function, and integration, from the start. What scales with tier is per-guild hourly budget and storage. Tiers are per guild, bought through Discord's store, and both upgrades and downgrades take effect immediately. Full pricing detail is on /pricing .

LimitFreePlusProUltra
Price / month$0$2.99$5.99$14.99
Triggers includedAll 51 triggersAll 51 triggersAll 51 triggersAll 51 triggers
Authoring modesBlocks + JavaScriptBlocks + JavaScriptBlocks + JavaScriptBlocks + JavaScript
Write-only secretsIncludedIncludedIncludedIncluded
Execution logs & auditIncludedIncludedIncludedIncluded
Runtime / hour30s300s900s1,800s
Storage (KV + Media)0.5 MB1 MB5 MB10 MB
fetch() calls / hour1050100200

Runtime and fetch are rolling one-hour budgets. Fetch is checked live per call and exposed to the running script as ctx.fetchCallsRemaining, so a data-dependent loop can size itself against what's left; runtime is checked once, before a script is dispatched at all. Persistent storage is not hourly and does not reset: over budget, setKV and media uploads throw until entries or files are deleted. Usage against all three is shown on the guild's General page.

Runtime is the meter for Discord work too. A script blocks while each Discord call completes, so that wait lands in the run's duration: on the server these limits were measured against, 94% of all runtime was time spent waiting on Discord, at roughly 331ms a call. That's why there is no separate cap on Discord actions and none on how many times your scripts run: both are already paid for here, and a second counter on the same underlying thing only added another way to be cut off.

The cheapest way to stay inside runtime is a trigger filter , which is evaluated before the script starts: an event your filter rejects never runs and costs nothing at all. On that same server, filtering a handful of high-volume MessageCreate scripts cut script runs roughly twentyfold while the work those scripts actually did stayed identical.

The same on every tier, free included. A script may be up to 60,000 characters, and a scheduled script may run as often as every 5 minutes. Both were tier limits until August 2026 and are not any more: a longer or more frequent script already pays for itself through runtime, so pricing it twice bought nothing. Underneath, Jint (the JS engine) enforces a 30-second wall-clock timeout per execution, a 50-level recursion limit and a 100,000-statement cap; these protect the process itself, since one process can't offer unbounded wall-clock time no matter what a guild is paying for. fetch() additionally has a flat 30 requests/minute ceiling per guild on top of the hourly tier budget. Discord's own command-registration limits apply to everyone alike: 32-character command/option names, 100-character descriptions, and 25 options per command.

An unhandled error has occurred. Reload 🗙