Medusa/developer documentation/Configuration

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

modules/example/resources/config/settings.js
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:

TypeStored FormatDescription
"boolean"booleanOn/off toggle switch.
"switch"booleanTwo-label toggle switch with custom state labels (labels: [off, on]) and optional entitlement gating (addon: "<name>").
"string"stringSingle-line text input.
"text"stringMulti-line textarea.
"number"numberNumeric input with increment controls.
"select"stringDropdown selector with options array.
"color"stringHex color picker control (e.g. "#22c55e").
"channel"stringSingle Discord channel picker.
"channels"Array<string>Multi-select Discord channel picker.
"category"stringSingle Discord category picker.
"categories"Array<string>Multi-select Discord category picker.
"role"stringSingle Discord role selector.
"roles"Array<string>Multi-select Discord role selector.
"emoji"stringDiscord 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"objectDrag-and-drop card designer that stores a canvas layout document. See Canvas Cards.
"embed"objectFull embed builder with a live preview. Stores an embed template plus a lists map of per-item templates. See Embed Fields.
"server"stringPicks 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.

FormReads 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>" }

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:

modules/example/resources/dashboard/previews/template.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) and values(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, or null. Use it for cross-server connections.
  • this.medusa.logs.post(module, event, { guildId, ... }) and this.medusa.logs.wants(module, event, guildId) target the log channel configured for that server. Pass guildId in the payload whenever the payload has no guild, channel, member, message, or role to 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.
  • server must name a "server" field in the same group, and only top-level resource fields can use it, not list item 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/.

modules/example/resources/logs/itemLogs.js
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:

VerbDefault ColorStandard Hex Code
created, added, unbanned, unmuted, joinedGreen#22c55e
deleted, removed, bannedRed#ef4444
updated, changed, editedAmber#f59e0b
mutedOrange#f97316
moved, executedBlue#3b82f6
leftGrey#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 as server next to channels and events. 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.
  • LogInterface exposes server(guildId) (the configured log server or "") and target(guildId) (the log server, else guildId).
  • The sentFrom key in the logs translation 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.