Summary
The isolation architecture is great, but the worker ctx.discord shim exposes far less than the broker already implements, so plugins that only need one or two "action" calls (add a role, delete a message, react) are forced to declare system:raw-client and run fully un-sandboxed. That's a blunt trade: to react with ✅ we hand a plugin the entire bot process.
We just migrated the registry plugins and hit this wall. Only todo and reminders could stay sandboxed. levels, counting, moderation, giveaways, invite-tracker, confessions all went raw-client — several of them for a single missing shim method.
The ask: expand the sandboxed surface so most plugins never need raw-client. Keep raw-client as the rare escape hatch it was meant to be (voice, cross-plugin introspection), not the default for anything that touches a role or a message.
Tier 1 — wire the RPC methods the broker ALREADY implements (near-zero effort, biggest win)
core/rpc/broker.js implements 14 discord* handlers and core/rpc/methods.js declares all 14 with capabilities. But core/rpc/worker-bootstrap.js createShimContext().discord only wires 5 of them:
| Broker handler (exists) |
Declared in methods.js |
Exposed in worker shim? |
| sendRichMessage / sendDM / getGuild / getMember / fetchChannel |
✅ |
✅ |
| addRole / removeRole |
✅ |
❌ |
| deleteMessage |
✅ |
❌ |
| addReaction |
✅ |
❌ |
| timeout / kick / ban |
✅ |
❌ |
| sendEmbed / sendMessage |
✅ |
❌ |
The plumbing and capability gates are done — the methods are just not surfaced to plugins. Adding them to the discordProxy object is a handful of lines each and unblocks:
- levels → needs only
addRole (role rewards). Otherwise 100% sandbox-safe. This alone removes its raw-client.
- counting → needs
deleteMessage + addReaction. Removes its raw-client.
- moderation →
ban/kick/timeout/deleteMessage cover ban, kick, timeout, purge. Leaves only ticket-channel-create + lock/slowmode for Tier 3.
Proposed additions to the shim (mirror the existing 5):
addRole: (guildId, userId, roleId, reason) => rpc.call("discord.addRole", { guildId, userId, roleId, reason }),
removeRole: (guildId, userId, roleId, reason) => rpc.call("discord.removeRole", { guildId, userId, roleId, reason }),
deleteMessage:(channelId, messageId) => rpc.call("discord.deleteMessage",{ channelId, messageId }),
addReaction: (channelId, messageId, emoji) => rpc.call("discord.addReaction", { channelId, messageId, emoji }),
timeout: (guildId, userId, ms, reason) => rpc.call("discord.timeout", { guildId, userId, ms, reason }),
kick: (guildId, userId, reason) => rpc.call("discord.kick", { guildId, userId, reason }),
ban: (guildId, userId, reason, days) => rpc.call("discord.ban", { guildId, userId, reason, deleteMessageDays: days }),
(Confirm each broker case's expected params shape and match it.)
Tier 2 — real interaction replies (so sandboxed commands stay ephemeral)
Right now the interaction proxy in worker-bootstrap.js routes interaction.reply/editReply/followUp through discord.sendRichMessage — i.e. a public channel message. Consequences for every isolated command:
ephemeral: true is silently dropped (todo/reminders leak "🗑️ Task removed" into the channel).
- No
deferReply for slow work, no real editReply, no modal/component responses.
Proposal: route interaction responses back through the gateway on the real interaction token (Core holds it) instead of faking them as channel sends. A minimal interaction.reply({ ephemeral }) + deferReply + editReply would make the sandbox viable for the majority of slash commands that expect private responses.
Tier 3 — new RPC methods for the remaining raw-client cases
Each of these is a specific capability a plugin needs that has no sandbox equivalent today. Adding scoped RPC methods (capability-gated like the rest) would retire the last raw-client holdouts:
| Need |
Plugin blocked |
Proposed RPC |
| Create/edit/delete a channel; edit permission overwrites (lock); set slowmode |
moderation (tickets, lock, slowmode) |
discord.createChannel, discord.editChannel, discord.setSlowmode, discord.setPermissionOverwrite (gated by ManageChannels) |
| Create + post via channel webhook |
confessions |
discord.createWebhook / discord.sendViaWebhook (gated by ManageWebhooks) |
| List a guild's invites + who invited whom |
invite-tracker |
discord.fetchInvites (gated by ManageGuild), plus forward inviteCreate / inviteDelete events (currently not in EVENTS_TO_FORWARD) |
| Button/component interactions + edit the message components |
giveaways |
forward component interactions to workers; allow interaction.update() / message component edit over RPC; serialize a components payload on send |
| Edit an already-sent message (embeds/components) |
giveaways |
discord.editMessage (gated by SendMessages/ManageMessages) |
Also: thicker event payloads (opt-in)
core/PluginManager.js _serializeDiscordEvent strips messages to { id, content, author, guildId, channelId } and members to ids. That's fine for most, but a couple of features die quietly:
- message attachments / reactions (needed for media-aware plugins)
- full role objects on members (levels/invite-tracker do a role lookup)
Suggestion: allow a plugin to opt into a richer serialization for specific events (bounded, still no live objects), or expose discord.getMessage(channelId, messageId) so a plugin can fetch the extra fields on demand instead of running raw-client for them.
Impact if Tier 1 + 2 land
| Plugin |
Today |
After Tier 1 |
After Tier 1+2+3 |
| levels |
raw-client |
isolated |
isolated |
| counting |
raw-client |
isolated |
isolated |
| moderation |
raw-client |
mostly isolated (tickets/lock remain) |
isolated |
| giveaways |
raw-client |
raw-client |
isolated |
| invite-tracker |
raw-client |
raw-client |
isolated |
| confessions |
raw-client |
raw-client |
isolated |
Tier 1 is a few lines and immediately reclaims 2 plugins with a third mostly there. Worth doing on its own.
Filed after migrating the registry plugins to manifest v2. ctx.discord currently exposes: sendToChannel, sendDM, getGuild, getMember, fetchChannel. Everything above is additive and stays behind the existing capability gates.
Summary
The isolation architecture is great, but the worker
ctx.discordshim exposes far less than the broker already implements, so plugins that only need one or two "action" calls (add a role, delete a message, react) are forced to declaresystem:raw-clientand run fully un-sandboxed. That's a blunt trade: to react with ✅ we hand a plugin the entire bot process.We just migrated the registry plugins and hit this wall. Only todo and reminders could stay sandboxed. levels, counting, moderation, giveaways, invite-tracker, confessions all went
raw-client— several of them for a single missing shim method.The ask: expand the sandboxed surface so most plugins never need
raw-client. Keepraw-clientas the rare escape hatch it was meant to be (voice, cross-plugin introspection), not the default for anything that touches a role or a message.Tier 1 — wire the RPC methods the broker ALREADY implements (near-zero effort, biggest win)
core/rpc/broker.jsimplements 14discord*handlers andcore/rpc/methods.jsdeclares all 14 with capabilities. Butcore/rpc/worker-bootstrap.jscreateShimContext().discordonly wires 5 of them:The plumbing and capability gates are done — the methods are just not surfaced to plugins. Adding them to the
discordProxyobject is a handful of lines each and unblocks:addRole(role rewards). Otherwise 100% sandbox-safe. This alone removes its raw-client.deleteMessage+addReaction. Removes its raw-client.ban/kick/timeout/deleteMessagecover ban, kick, timeout, purge. Leaves only ticket-channel-create + lock/slowmode for Tier 3.Proposed additions to the shim (mirror the existing 5):
(Confirm each broker case's expected
paramsshape and match it.)Tier 2 — real interaction replies (so sandboxed commands stay ephemeral)
Right now the interaction proxy in
worker-bootstrap.jsroutesinteraction.reply/editReply/followUpthroughdiscord.sendRichMessage— i.e. a public channel message. Consequences for every isolated command:ephemeral: trueis silently dropped (todo/reminders leak "🗑️ Task removed" into the channel).deferReplyfor slow work, no realeditReply, no modal/component responses.Proposal: route interaction responses back through the gateway on the real interaction token (Core holds it) instead of faking them as channel sends. A minimal
interaction.reply({ ephemeral })+deferReply+editReplywould make the sandbox viable for the majority of slash commands that expect private responses.Tier 3 — new RPC methods for the remaining raw-client cases
Each of these is a specific capability a plugin needs that has no sandbox equivalent today. Adding scoped RPC methods (capability-gated like the rest) would retire the last raw-client holdouts:
discord.createChannel,discord.editChannel,discord.setSlowmode,discord.setPermissionOverwrite(gated byManageChannels)discord.createWebhook/discord.sendViaWebhook(gated byManageWebhooks)discord.fetchInvites(gated byManageGuild), plus forwardinviteCreate/inviteDeleteevents (currently not inEVENTS_TO_FORWARD)interaction.update()/ message component edit over RPC; serialize acomponentspayload on senddiscord.editMessage(gated bySendMessages/ManageMessages)Also: thicker event payloads (opt-in)
core/PluginManager.js_serializeDiscordEventstrips messages to{ id, content, author, guildId, channelId }and members to ids. That's fine for most, but a couple of features die quietly:Suggestion: allow a plugin to opt into a richer serialization for specific events (bounded, still no live objects), or expose
discord.getMessage(channelId, messageId)so a plugin can fetch the extra fields on demand instead of running raw-client for them.Impact if Tier 1 + 2 land
Tier 1 is a few lines and immediately reclaims 2 plugins with a third mostly there. Worth doing on its own.
Filed after migrating the registry plugins to manifest v2.
ctx.discordcurrently exposes:sendToChannel, sendDM, getGuild, getMember, fetchChannel. Everything above is additive and stays behind the existing capability gates.