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 30s
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/30/60/120 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.
OpenRouter AI
Scripts can invoke Large Language Models via OpenRouter using the global
aiChat(prompt, options?) function. OpenRouter must first be connected
by a guild administrator on the Integrations page with an API key.
API keys are stored write-only in the database and never exposed to scripts or the browser.
Use hasAi() to test if OpenRouter is configured before making calls.
Scripts can specify any OpenRouter model (defaulting to the server's configured default model, initially google/gemini-3.5-flash-lite),
customize reasoning effort (defaulting to the server's configured default reasoning, medium), tokens, and temperature, and toggle server-side tools
like webFetch, webSearch, and datetime with
convenient boolean flags or custom tools arrays.
All AI calls are automatically tracked in execution traces and logs, displaying input prompts, response texts, token usage, and durations.
Ask Mallard: Once OpenRouter is connected, an "Ask Mallard" assistant appears in the bottom-right corner of all guild dashboard pages. Administrators and scripters can chat with Mallard to ask questions about scripting, triggers, functions, and documentation, using the server's configured key and default model with live documentation retrieval.
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 is a reserved system key:
getKV/setKV/deleteKV all fail closed on it (a
script-visible error), so it can only be read through
ctx.env 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/4/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.
Reading a key and writing it back needs a lock when scripts overlap.
Writes to one key never interleave, but a plain getKV reads without
waiting, so on a tier where several scripts run at once, two of them can read the same
counter, both add one, and the second write overwrites the first. Use the locked paths
for any read-modify-write: incrKV(key, by) for counters (XP, message counts,
ticket numbers), updateKV(key, fn) for anything else, or
getKV(key, { lock: true }) followed by setKV. Each holds the key
from the read until the run ends, so another script touching that key waits its turn.
A key you only read (a config record) should stay a plain getKV, so it never
makes other scripts wait.
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.
Leaderboards (sorted sets)
For XP, levels and any "top 10", keep scores in a sorted set rather than one key per member:
zIncr("xp", ctx.actorId, 15) adds to a score atomically (no lock needed, on any
tier), zRange("xp", 0, 9, { withScores: true }) reads the top ten, and
zRank("xp", userId) gives someone's place without reading anyone else's. Reading a
leaderboard costs the same whether the set holds ten members or ten thousand.
A set holds up to 10,000 members on Free (25,000 Plus, 50,000 Pro, 100,000 Ultra) and a server can have 100 sets. Each member counts its length plus 16 bytes towards the same storage cap as the key-value store. Sets are listed read-only on the Database page, where they can be deleted, and they're included in backups.
Time zones & Discord timestamps
Scripts run on UTC and JavaScript here has no Intl, so local time goes through
helper functions. For anything shown in a message, the best answer is usually not to convert at
all: formatTimestamp() produces Discord's <t:…> markup, which every
reader sees in their own time zone and language.
When an organiser types a time, read it in their zone:
formatTimestamp(ctx.args.when, "F", "Europe/London") accepts
"2026-10-02 20:00", "20:00", "8pm",
"tomorrow 9am", "fri 8:30pm" or "in 2h", and everyone sees
the right local time. parseTime() does the same reading and returns an ISO string, for
runLater() reminders. A member's zone is worth storing once
(setKV("tz:" + ctx.actorId, "America/New_York")).
For logic rather than display, zonedTime() gives the local date, hour and weekday,
fromZonedTime() goes the other way, formatDate() writes plain text such as
a channel name, and nextOccurrence() answers "next Friday 20:00 London time" from a
cron expression. Daylight saving is handled; zones are IANA names.
Libraries & events
Libraries. A script with the Library trigger never runs by itself;
it puts shared helpers on module.exports and others load them with
const mod = require("mod-helpers"). The name must be a plain string, because libraries
are looked up when the run is queued. A library runs inside the calling script's run and budget, once
per run however often it's required, can require other libraries (up to 5 deep, 10 in total), and
can't be deleted while a script still requires it.
Events. emit("ticket.closed", payload) runs every enabled
CustomEvent script subscribed to that name, a couple of seconds later, each as its own
budgeted run with the payload in ctx.event.payload. The emitter doesn't need to know
who listens, so one event can save a transcript, update stats and post to the mod log from three
small scripts. A run can emit 5 events and a server 60 a minute; payloads are up to 16 KB, and
events can chain at most 3 deep.
Layouts (Components V2)
Pass layout instead of content/embeds to send Discord's
newer message layout: containers with an accent bar, sections with a thumbnail or button beside
the text, image galleries, separators, button rows and menus. It suits ticket panels, role menus,
profiles and leaderboards. A layout message can't also carry content, embeds or stickers, and it
stays a layout message for good (editMessage can change the layout, not turn it back). Up to 40
components and 4,000 characters of text per message.
sendMessage(channelId, { layout: [
{ type: "container", accentColor: 0x5865F2, children: [
{ type: "section", text: ["## Support", "Open a ticket and a moderator will reply."],
accessory: { thumbnail: ctx.guild.iconUrl } },
{ type: "separator" },
{ type: "buttons", buttons: [{ label: "Open ticket", handler: "ticket-open", style: "success" }] }
]}
]});
// A leaderboard card
const top = zRange("xp", 0, 9, { withScores: true });
sendMessage(channelId, { layout: [{ type: "container", accentColor: 0xF1C40F, children: [
{ type: "text", text: "## 🏆 Top 10" },
{ type: "text", text: top.map((e, i) => `**${i + 1}.** <@${e.member}> · ${e.score} XP`).join("\n") }
]}]});
A gallery takes { type: "gallery", items: [{ url, description }] } with 1-10 images;
attach a Media-page image with files and point at it as
attachment://name.png. Webhook messages don't support layouts yet. A layout message
has no content, so ctx.message.content on it is the text of its text blocks, one per line.
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
A cache of every message the bot saw in the last 3 hours, 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() answers from it when it can (reaction counts are kept current)
and falls back to Discord for anything older or for a poll; getMessages()
always fetches live, so it returns complete history rather than only what the bot recently
saw. The cache survives bot restarts, and a deleted message leaves it as soon as its
MessageDelete has been handled.
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.
YouTube
Link one or more YouTube channels on the Integrations page to enable the
YouTubeVideoPublished, YouTubeVideoUpdated,
YouTubeVideoDeleted, YouTubeStreamOnline, and
YouTubeStreamOffline triggers. Entering a channel's handle (e.g. @dougdoug),
custom URL, or ID resolves the channel and subscribes to Google's WebSub push hub automatically.
All linked channels route into the same script handlers, allowing branching via ctx.youtube.channelId
or ctx.youtube.channelTitle.
Every event delivers a full ctx.youtube object containing video metadata (ID, URL,
title, description, timestamps, and thumbnail). It also provides ctx.youtube.isShort:
a boolean that evaluates to true for YouTube Shorts and false for regular
long-form videos,
enabling clean filtering (if (ctx.youtube.isShort) return;) without consuming API quota.
The maximum number of channels linked per server scales with tier: 1 on Free, 3 on Plus, 6 on Pro, and 12 on Ultra.
Incoming webhooks
A script with the IncomingWebhook trigger can have its own URL, created on the
Integrations page, which any service can POST to: GitHub, Ko-fi, Patreon, Stripe, uptime monitors,
CI, game servers or your own code. The request arrives as ctx.webhook: method, query,
lower-cased headers (without authorization, cookies or proxy headers), the raw body, and
json or form when it parses. The sender gets 202 as soon as the
run is queued, so a script can't choose the response. The URL is shown once; regenerate it if it
leaks.
Signature checks. Pick GitHub (X-Hub-Signature-256), Stripe
(Stripe-Signature, 5-minute tolerance), Ko-fi (the verification token in the body) or a
generic HMAC-SHA256 header, and name the secret on the Database page that signs it.
ctx.webhook.verified says whether it passed, or the hook can refuse unverified requests
outright. A delivery ID header (such as X-GitHub-Delivery) makes a retried delivery run once.
Bodies are capped at 256 KB. Free allows one webhook URL and 25 on paid tiers; each accepts 60 requests an hour on Free (300 Plus, 1,000 Pro, 3,000 Ultra), and every accepted request is an ordinary budgeted run.
RSS & Atom feeds
Follow feeds on the Integrations page and each new item runs your FeedItem scripts
with ctx.feed: the feed's URL and title, and the item's title, link, dates, author,
categories, an image when the feed has one, and a plain-text summary (HTML removed, up to 2,000
characters). RSS 2.0 and Atom 1.0 both work, which covers blogs, subreddits
(reddit.com/r/name/.rss), GitHub releases (…/releases.atom), Steam news,
podcasts, status pages and Mastodon or Bluesky profiles.
Following a feed records what's already in it, so only later items fire, at most 5 per check. Feeds are checked every 60 minutes on Free (30 Plus, 15 Pro, 5 Ultra), and a server can follow 3 feeds on Free (10, 25 and 50 on the paid tiers). A feed that keeps failing is checked less often until it recovers.
AutoMod
Discord's AutoMod blocks a message before anyone sees it and costs no runtime, so it's the right
tool for word filters and invite blocking. Scripts can manage rules with
createAutoModRule(), modifyAutoModRule() and friends: a /setup wizard, a
/raidmode toggle, or a word list kept in the key-value store. To keep a mistake from silencing a
server, rules that would match every message are refused, a run can change at most 5 rules, and
the audit log names the script. Rules Mallard created are listed on the General page with a
switch to turn each off. Needs the Manage Server permission.
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. Work that must happen
later (lifting a temp ban, a reminder, ending a giveaway) is queued with
runLater(scriptName, seconds, payload), which runs a Delayed
script when it comes due, 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
(or isn't a member of the server, whom sendDm never messages), 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 .
| Limit | Free | Plus | Pro | Ultra |
|---|---|---|---|---|
| Price / month | $0 | $2.99 | $5.99 | $14.99 |
| Triggers included | All 84 triggers | All 84 triggers | All 84 triggers | All 84 triggers |
| Script editor | Monaco, full API | Monaco, full API | Monaco, full API | Monaco, full API |
| Write-only secrets | Included | Included | Included | Included |
| Execution logs & audit | Included | Included | Included | Included |
| Runtime / hour | 30s | 150s | 300s | 600s |
| Storage (KV + Media) | 0.5 MB | 1 MB | 4 MB | 10 MB |
| fetch() calls / hour | 10 | 30 | 60 | 120 |
| Concurrent scripts | 1 | 2 | 4 | 8 |
| YouTube channels | 1 | 3 | 6 | 12 |
| RSS / Atom feeds | 3, every 60 min | 10, every 30 min | 25, every 15 min | 50, every 5 min |
| Incoming webhooks | 1 URL, 60 req/h each | 25 URLs, 300 req/h each | 25 URLs, 1,000 req/h each | 25 URLs, 3,000 req/h each |
| Leaderboard size | 10,000 members | 25,000 members | 50,000 members | 100,000 members |
| Bot profile from scripts | Dashboard only | Dashboard only | Included | Included |
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
statement cap of 100,000, 250,000, 500,000 and 1,000,000 on Free,
Plus, Pro and Ultra (scaled with the runtime budget it sits inside); these protect
the process itself, since one process can't offer unbounded wall-clock time no
matter what a guild is paying for. A guild can also hold 100/250/500/1000 pending
runLater() jobs. 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.
1/2/4/8 of a guild's scripts can run at the same time,
Free through Ultra — scaling with tier, unlike the limits above. A slow script no
longer blocks quick ones queued behind it. Two scripts updating the same
key-value entry must use incrKV/updateKV (or a locked
getKV) so the second waits its turn rather than racing it — see
Key-value store
.