Configuration
Define zero-code configuration forms, conditional field visibility, and audit logging streams.
Medusa configuration is schema-driven. You do not need to write React or HTML components to configure a module. Instead, your module declares configuration forms and audit logging streams inside the resources/ directory.
Configuration Schemas (resources/config/)
Configuration forms define settings that server administrators manage in the web dashboard. Classes extend Configs from #medusa/modules and reside in resources/config/.
import { Configs } from "#medusa/modules";
export class SettingsConfig extends Configs {
constructor(medusa) {
super(medusa, {
name: "settings",
label: "Example Settings",
description: "Manage core settings, channels, and operational limits."
});
}
schema() {
return {
enabled: {
type: "boolean",
label: "Module Enabled",
default: true
},
alertChannel: {
type: "channel",
label: "Alert Channel",
default: ""
},
adminRole: {
type: "role",
label: "Manager Role",
default: ""
},
allowedRoles: {
type: "roles",
label: "Authorized Staff Roles",
default: []
},
currencySymbol: {
type: "string",
label: "Currency Symbol",
default: "$"
},
maxStock: {
type: "number",
label: "Maximum Stock Limit",
default: 100
},
themeColor: {
type: "color",
label: "Accent Color",
default: "#22c55e"
},
deliveryMode: {
type: "select",
label: "Delivery Mode",
default: "instant",
options: [
{ value: "instant", label: "Instant Delivery" },
{ value: "manual", label: "Manual Approval" }
]
},
rewards: {
type: "list",
label: "Milestone Rewards",
default: [],
item: {
level: { type: "number", label: "Level", default: 1 },
role: { type: "role", label: "Reward Role", default: "" },
coins: { type: "number", label: "Coins Awarded", default: 50 }
}
}
};
}
}Supported Field Types
Medusa supports 18 field types:
| Type | Stored Format | Description |
|---|---|---|
"boolean" | boolean | On/off toggle switch. |
"switch" | boolean | Two-label toggle switch with custom state labels (labels: [off, on]) and optional entitlement gating (addon: "<name>"). |
"string" | string | Single-line text input. |
"text" | string | Multi-line textarea. |
"number" | number | Numeric input with increment controls. |
"select" | string | Dropdown selector with options array. |
"color" | string | Hex color picker control (e.g. "#22c55e"). |
"channel" | string | Single Discord channel picker. |
"channels" | Array<string> | Multi-select Discord channel picker. |
"category" | string | Single Discord category picker. |
"categories" | Array<string> | Multi-select Discord category picker. |
"role" | string | Single Discord role selector. |
"roles" | Array<string> | Multi-select Discord role selector. |
"emoji" | string | Discord emoji picker (custom and Unicode). |
"questions" | Array<object> | Modal builder. Holds up to five items: questions the member answers, and info text blocks ({ type: "info", content }) shown as read-only text. |
"list" | Array<object> | Dynamic repeating list with nested item schema. |
"canvas" | object | Drag-and-drop card designer that stores a canvas layout document. See Canvas Cards. |
"embed" | object | Full embed builder with a live preview. Stores an embed template plus a lists map of per-item templates. See Embed Fields. |
"server" | string | Picks another server the bot is in. Pair it with resource fields through server to connect them to that server. See Cross-Server Connections. |
Sections (type: "section")
Split a long configuration card into headed sections with a section entry. Fields inside a section are stored flat on the group like any other field, so visibleWhen, server connections and values() work unchanged, and existing saved values keep loading. Keys must be unique across all sections of one group.
schema() {
return {
enabled: { type: "boolean", default: true, label: "Enable Reminders" },
limits: {
type: "section",
label: "Limits",
fields: {
maxPerUser: { type: "number", default: 10, label: "Maximum Active Reminders Per Member" },
maxDays: { type: "number", default: 365, label: "Longest Reminder in Days" }
}
}
};
}Fields outside any section render first, then each section as an expandable block. Section labels translate through schema.<module>.config.<group>.section.<section>.label and .desc in common.json.
Dynamic Select Sources (source)
A "select" field or list item subfield can take its options from a list field instead of a fixed options array. Each list entry becomes an option, keyed by its id, key or name.
| Form | Reads From |
|---|---|
source: "<field>" | A list field in the same group. |
source: "<group>.<field>" | A list field in another group of the same module. |
Set empty to the label shown when the source list has no entries. The dashboard keeps the dropdown and shows that label instead of falling back to a text input. Translate it with the schema.<module>.config.<group>.<field>.<subfield>.empty key.
tier: {
type: "select",
source: "tiers.levels",
empty: "No support tiers found",
default: "",
label: "Starting Support Tier"
}Placeholder Buttons (placeholders)
string and text fields, including list item subfields, accept a placeholders array of { key, label, sample }. The dashboard renders one button per entry under the input that inserts %key% at the cursor. See Placeholders.
naming: {
type: "string",
default: "ticket-%number%",
label: "Channel Naming",
placeholders: [{ key: "number", label: "Ticket Number", sample: "42" }]
}Embed Fields (embed)
An embed field renders the dashboard embed builder (author, title, description, fields, image, thumbnail, footer) with a live preview, and stores the embed template as an object. Pass the value straight to this.medusa.embeds.resolve. It works as a top-level field and as a list item subfield.
placeholders adds insert buttons under the description. lists declares list placeholders: each entry { key, label, placeholders } adds %key% to those buttons and a textarea for the item template, stored under value.lists[key]. When the handler resolves the embed with an array under the same token, the item template is filled once per array entry and the results replace %key%. Give list placeholder sample values as arrays to preview several items.
To preview live rows instead of samples, set source to a sibling list field in the same group and match to { rowField: itemField }. The preview fills the template once per row whose rowField equals the embed item's itemField, so a panel shows the categories assigned to it as they are edited. Each placeholder then takes field, the row key it reads, in place of sample. format: "role" shows a role id as @Role Name. %custom_emoji_<name>% tokens render as the bot emoji in the preview.
embed: {
type: "embed",
default: {
title: "Open a Ticket",
description: "%categories%\nPick a category below.",
lists: { categories: "> * %category_emoji% %category%\n-# %category_description%\n" }
},
label: "Panel Embed",
lists: [
{
key: "categories",
label: "Categories",
source: "categories",
match: { panel: "id" },
placeholders: [
{ key: "category", label: "Category Name", field: "label" },
{ key: "category_emoji", label: "Category Emoji", field: "emoji" },
{ key: "category_description", label: "Category Description", field: "description" }
]
}
]
}this.medusa.embeds.resolve(panel.embed, {
categories: panel.categories.map(category => ({
category: category.label,
category_emoji: category.emoji,
category_description: category.description
}))
});Translate the labels with schema.<module>.config.<group>.<field>.lists.<key>, ...lists.<key>.placeholders.<placeholder> and ...placeholders.<placeholder>.
Migrating Old Values (migrate)
Any field or list item subfield can take a migrate function. When a stored group (or list entry) has saved values but none for that field, the config reader calls migrate(stored) and uses the result in place of the default. Use it when replacing old fields with a new one, so existing setups keep their content. The result is written back the next time the group is saved from the dashboard.
embed: {
type: "embed",
default: this.embed(),
label: "Panel Embed",
migrate: item => ({ title: item.title, description: item.description })
}Conditional Visibility (visibleWhen)
Fields and list item subfields can be shown or hidden conditionally using visibleWhen. The field only renders in the dashboard when the specified condition is met.
visibleWhen: { field: "<targetField>", value: "<expectedValue>" }Dropdown Match
Show a field when a dropdown selects a specific option:
action: {
type: "select",
default: "ban",
label: "Trap Action",
options: [
{ value: "ban", label: "Ban Member" },
{ value: "kick", label: "Kick Member" },
{ value: "timeout", label: "Timeout Member" },
{ value: "delete", label: "Delete Message Only" }
]
},
timeoutMinutes: {
type: "number",
default: 60,
label: "Timeout Duration in Minutes",
visibleWhen: { field: "action", value: "timeout" }
}You can also match any option in a list:
visibleWhen: { field: "action", in: ["mute", "timeout"] }Switch Match
Show a field when a switch is turned on:
enabled: {
type: "boolean",
default: false,
label: "Enable Anti-Raid"
},
joinThreshold: {
type: "number",
default: 10,
label: "Join Threshold",
visibleWhen: { field: "enabled", value: true }
}Textbox Typed In
Show a field only when a textbox has text entered:
apiKey: {
type: "string",
default: "",
label: "API Key"
},
apiEndpoint: {
type: "string",
default: "",
label: "Custom Endpoint",
visibleWhen: { field: "apiKey", notEmpty: true }
}You can also check for field presence shorthand:
visibleWhen: { field: "apiKey" }Live Configuration Previews
To render a live preview beside your configuration form, pass preview: "<id>" in your Configs constructor:
export class SettingsConfig extends Configs {
constructor(medusa) {
super(medusa, {
name: "settings",
label: "Template Settings",
description: "Manage core module settings.",
preview: "template"
});
}
}Then create a React preview component in resources/dashboard/previews/<id>.tsx:
"use client";
import type { PreviewProps } from "@/components/pages/types";
export default function TemplateConfigPreview({ values, user, module }: PreviewProps) {
const accentColor = typeof values.embedColor === "string" ? values.embedColor : "#5865f2";
return (
<div className="rounded-xl border border-border bg-card p-4">
<h4 className="text-sm font-semibold">Live Preview</h4>
<p className="text-xs text-muted-foreground">Accent: {accentColor}</p>
</div>
);
}Reading Configuration in Code
Configuration is saved per server from the dashboard, so always pass the server the code is acting in. Use the guildId of the interaction, message, member, channel, or stored record in front of you:
const isEnabled = this.medusa.modules.config.value(this.module, "settings", "enabled", interaction.guildId);
const alertChannel = this.medusa.modules.config.value(this.module, "settings", "alertChannel", member.guild.id);
const allSettings = this.medusa.modules.config.values(this.module, "settings", message.guildId);Each group resolves in three layers, per key: the schema default, then the shared value (module:<id>), then the server's own value (module:<id>:<guildId>). A server that has never saved a group reads the shared value, and saving one group never hides the shared values of another.
Clocks run once for the whole bot. Loop over servers and read config inside the loop:
for (const guild of this.medusa.discord.client.guilds.cache.values()) {
if (!this.medusa.modules.enabled(this.module, guild.id)) continue;
if (!this.medusa.modules.config.value(this.module, "settings", "enabled", guild.id)) continue;
}To save from code, pass the server as the last argument: this.medusa.modules.save(this.module, "settings", values, interaction.guildId).
Shared Configuration Groups
Some settings belong to the bot, not a server, such as a Lavalink node or an external API endpoint. Mark the group global: true and it always reads and saves the shared value, whatever server is selected. The dashboard labels it All servers.
super(medusa, {
name: "lavalink",
label: "Lavalink Connection",
global: true
});Multi-Server Compatibility
Medusa supports operating across multiple authorized Discord servers simultaneously. The primary server ID is declared in GUILD_ID, and additional authorized servers are declared in SERVERS as an object mapping server IDs to server metadata ({ [guildId]: { name?: string } }). Backward compatibility with ALLOWED_GUILDS is preserved.
this.medusa.modules.config.value(module, group, key, guildId)andvalues(module, group, guildId)read configuration for one server.this.medusa.discord.servers()returns all currently connected authorized servers.this.medusa.discord.connection(serverId)returns one authorized server the bot is in, ornull. Use it for cross-server connections.this.medusa.logs.post(module, event, { guildId, ... })andthis.medusa.logs.wants(module, event, guildId)target the log channel configured for that server. PassguildIdin the payload whenever the payload has noguild,channel,member,message, orroleto infer it from, otherwise the log goes to the primary server.- Module routes called from the dashboard read the selected server with
this.guild(request).
Cross-Server Connections
A group can connect to a second server. Add a "server" field, then point any channel, channels, category, categories, role or roles field at it with server: "<field key>". That field lists the connected server's resources instead of the selected server's. Until a server is chosen, it shows Select a server first.
schema() {
return {
server: {
type: "server",
default: "",
label: "Appeal Server"
},
channel: {
type: "channel",
server: "server",
default: "",
label: "Panel Channel"
},
pingRoles: {
type: "roles",
server: "server",
default: [],
label: "Ping Roles"
}
};
}- The server list only shows servers the bot is in that the dashboard user can access, minus the server being configured.
servermust name a"server"field in the same group, and only top-level resource fields can use it, notlistitem fields. Anything else throws when the module loads.- Configuration import only remaps IDs from the selected server, so connected IDs are kept as they are.
Resolve the connected server in code with this.medusa.discord.connection(serverId). It returns the Guild when the bot is in that server and the server is authorized, otherwise null. Look connected IDs up on that guild so a stale value can never reach another server:
const settings = this.medusa.modules.config.values(this.module, "panel", interaction.guildId);
const server = this.medusa.discord.connection(settings.server);
const channel = server ? server.channels.cache.get(settings.channel) : null;Audit Logging Schemas (resources/logs/)
The logging system sends Discord audit embeds to channels selected by server administrators. Classes extend Logs from #medusa/modules and reside in resources/logs/.
import { Logs } from "#medusa/modules";
export class ItemLogs extends Logs {
constructor(medusa) {
super(medusa, {
name: "itemLogs",
label: "Item Logs",
description: "Audit trail for item creation, updates, and purchases.",
icon: "box",
colour: "#22c55e"
});
}
events() {
return {
created: {
label: "Item Created",
title: "%custom_emoji_check% • <server> » Item Created",
summary: "%subject% was created by %actor%.",
verb: "created",
color: "#22c55e",
default: true
},
deleted: {
label: "Item Deleted",
title: "%custom_emoji_cross% • <server> » Item Deleted",
summary: "%subject% was deleted by %actor%.",
verb: "deleted",
color: "#ef4444",
default: true
},
purchased: {
label: "Item Purchased",
title: "<server> » Item Purchased",
summary: "%actor% purchased %subject%.",
verb: "executed",
color: "#3b82f6",
default: true
}
};
}
}Standard Action Verbs and Colors
When an event specifies a verb, Medusa assigns colors automatically if not explicitly overridden:
| Verb | Default Color | Standard Hex Code |
|---|---|---|
created, added, unbanned, unmuted, joined | Green | #22c55e |
deleted, removed, banned | Red | #ef4444 |
updated, changed, edited | Amber | #f59e0b |
muted | Orange | #f97316 |
moved, executed | Blue | #3b82f6 |
left | Grey | #64748b |
Dispatching Log Events
Dispatch logs from handlers using this.medusa.logs.post():
await this.medusa.logs.post(this.module, "purchased", {
actor: interaction.user,
subject: item.name,
fields: [
{ name: "Price", value: `$${item.price}`, inline: true },
{ name: "Remaining Stock", value: `${item.quantity}`, inline: true }
],
tokens: {
item_id: item.identifier
}
});If the administrator has disabled the event or has not set a logging channel, Medusa skips dispatching without errors.
Log Server
Every module's Log Channels group starts with a Log Server picker (a type: "server" field keyed server). Each category channel field carries server: "server", so its dropdown lists the chosen server's channels. Leaving it on This Server keeps logs in the server where the event happened.
When a log server is set, this.medusa.logs.post() sends to the category channel in that server instead. The embed is still built in the source server's scope (title <server> token, thumbnail, footer), and gets an author line Sent from <source server> with the source server's icon. Modules need no changes: keep passing the source guildId.
- Stored per source server in the
logs:<module>:<guildId>bucket asservernext tochannelsandevents. An empty value means the source server. - The log server must be an authorized server the bot is in (
this.medusa.discord.connection(serverId)), and the dashboard user must have access to it. - Channels that do not belong to the log server are dropped on save and ignored at send time.
LogInterfaceexposesserver(guildId)(the configured log server or"") andtarget(guildId)(the log server, elseguildId).- The
sentFromkey in thelogstranslation group localizes the author line (%server%is the source server name). - Bulk-applying a channel from the Logging index resets the selected modules' log server to the current server, since the bulk picker lists current-server channels.