Skip to content
mosaic

Docs

Write your own module

Everything you need to get a module into Mosaic: structure, manifest, a code example and pull request rules.

01

How it works

Every module is its own folder in the modules/ directory of the bot repository. The bot loads modules at startup, and the website reads the manifest to build the module page and settings form on its own.

Modules are isolated: each has its own commands, events, settings and translations. A module can’t read other modules’ data directly — only through the bot’s public API.

02

Quick start

You’ll need Python 3.12+, uv and a test Discord server.

  1. Fork the mosaicbot-dev1/mosaic-bot-core repository and clone it.
  2. Install dependencies: uv sync.
  3. Scaffold a module: uv run mosaic create birthdays.
  4. Run the bot locally against your test server: uv run mosaic dev.
terminal
git clone https://github.com/<you>/mosaic-bot-core.git
cd mosaic-bot-core
uv sync
uv run mosaic create birthdays
uv run mosaic dev --guild 123456789012345678

03

Module structure

A minimal module is a manifest and an entry point. Everything else is optional.

modules/birthdays/
├── module.toml
├── __init__.py
├── commands.py
├── events.py
├── locales/
│   ├── uk.json
│   ├── en.json
│   └── pl.json
└── README.md
module.toml
manifest: id, version, author, permissions and settings schema
__init__.py
entry point: a Python package that registers commands, events and hooks
commands.py
slash commands and command groups
events.py
Discord event handlers and scheduled jobs
locales/
module translations, at least uk and en
README.md
description and command list for the catalog

04

Manifest

The website builds the settings form straight from settings. Fields are grouped with section; available types: boolean, number, text, select, color, channel and channels, role and roles, plus pairs — “number → value” rules such as “level 10 → role”.

modules/birthdays/module.toml
[module]
id = "birthdays"
name = "Birthdays"
version = "1.2.0"
category = "community"
author = "olha-codes"
permissions = ["ManageRoles", "SendMessages"]
network = []

[[settings]]
section = "general"
key = "announceChannel"
type = "channel"
channel_type = "text"

[[settings]]
section = "general"
key = "birthdayRole"
type = "role"

[[settings]]
section = "general"
key = "timezone"
type = "select"
options = ["Europe/Kyiv", "Europe/Warsaw", "UTC"]
default = "Europe/Kyiv"

[[settings]]
section = "messages"
key = "birthdayMessage"
type = "text"
default = "Happy birthday, {user}!"
max_length = 300

05

Code example

A module is a Python package with a Module object that reads the manifest. Settings are typed from the manifest schema, and translations are available through ctx.t.

modules/birthdays/__init__.py
from mosaic import Context, Module

module = Module.from_manifest(__file__)


@module.on_enable
async def on_enable(ctx: Context) -> None:
    ctx.log.info("birthdays enabled on %s", ctx.guild.name)


# Importing the files registers their commands and events on `module`
from . import commands, events  # noqa: E402,F401

Commands are async functions with the @birthday.command decorator. Store data only through ctx.store: that’s how the bot isolates modules and cleans up data on removal.

modules/birthdays/commands.py
from datetime import date

from discord import app_commands

from mosaic import CommandContext

from . import module

birthday = module.group("birthday", description="Birthdays")


@birthday.command("set", description="Set your birthday")
@app_commands.describe(day="Day of the month", month="Month, 1-12")
async def set_birthday(
    ctx: CommandContext,
    day: app_commands.Range[int, 1, 31],
    month: app_commands.Range[int, 1, 12],
) -> None:
    try:
        when = date(2000, month, day)  # a leap year, so 29 February is valid
    except ValueError:
        await ctx.reply(ctx.t("errors.invalidDate"), ephemeral=True)
        return

    # ctx.settings comes from the dashboard, ctx.store keeps this module's data
    await ctx.store.user(ctx.user.id).set("birthday", when.strftime("%m-%d"))
    await ctx.reply(ctx.t("set.success", date=when.strftime("%d.%m")), ephemeral=True)

06

Checking locally

Before opening a PR, run the same checks as CI: ruff, pytest and mosaic validate, which verifies the manifest, permissions and translations.

terminal
uv run ruff check modules/birthdays
uv run pytest
uv run mosaic validate birthdays

07

Pull request rules

A PR is accepted when the module follows these rules:

  • One module per PR. Title format: feat(module): birthdays.
  • ruff, pytest and mosaic validate pass without errors, and public functions have type hints.
  • The module requests only the Discord permissions it actually uses and explains them in the README.
  • No external requests without a network field in the manifest. Collect only the personal data you need.
  • Translations in at least Ukrainian and English.
  • A README with a description, command list and a screenshot of the settings.
  • MIT-licensed code, with no borrowed code under an incompatible license.

08

Review and release

Maintainers review PRs within seven days. Automated checks start immediately: build, tests, security analysis and manifest validation.

After merging, the module appears in the catalog with a “Community” badge in the next release — and you appear in the contributors list.