On this page

Your first module

A module is a folder with a manifest, a handler class, and optionally a frontend. Start from a bundled module, rename it, and make it compute something.

Module folder#

modules/my_module/
modules/my_module/
├── manifest.yaml           # required – identity, requirements, dependencies, frontend
├── module.py               # required – one Module subclass, instantiated once per process
├── events.py               # recommended – Pydantic payload shapes you emit
├── tests/                  # pytest
├── panel/index.tsx         # the module page entry
├── widgets/*.tsx           # dashboard cards
├── uv.lock                 # commit it – pins your Python dependencies
├── .venv/ · .dist/ · node_modules/   # created by the platform, gitignored
└── README.md               # rendered on the module detail page

Commit the manifest, module.py, tests, frontend sources, and uv.lock. Everything else is rebuilt.

Create a module in five steps#

  1. Copy a small, complete example — modules/performance_over_time has a panel and a widget.

    Terminal
    cp -r modules/performance_over_time modules/my_module
  2. Rewrite the identity in manifest.yaml: id, name, version, description, and shrink requirements to what you need. The id is lowercase snake_case and globally unique.

  3. Rewrite module.py: set id = "my_module" on the class (it must match the manifest), delete the handlers you do not need, keep one route.

  4. Start the platform with hot reload.

    Terminal
    make dev
  5. Import a log, open the module grid, click your module.

A minimal module that computes a result#

modules/my_module/module.py
from mate.sdk import Module, ModuleContext, job, on_event, route


class MyModule(Module):
    id = "my_module"  # must equal manifest.id

    async def _compute(self, ctx: ModuleContext) -> dict:
        async with ctx.event_log as log:
            rows = await log.duckdb_fetch(
                "SELECT activity, count(*) AS events FROM events GROUP BY 1 ORDER BY 2 DESC LIMIT 10"
            )
        return {"top_activities": [{"activity": a, "events": n} for a, n in rows]}

    @route.get("/top-activities")
    async def top_activities(self, ctx: ModuleContext) -> dict:
        cached = await ctx.cache.get("top_activities")
        if cached is not None:
            return cached
        result = await self._compute(ctx)
        await ctx.cache.set("top_activities", result)
        return result

    @on_event("log.imported")
    @job(progress=True, title="My Module - precompute")
    async def precompute(self, ctx: ModuleContext, payload: dict) -> None:
        await ctx.progress.update(0.1, "Loading log")
        await ctx.cache.set("top_activities", await self._compute(ctx))
        await ctx.progress.update(1.0, "Done")
modules/my_module/manifest.yaml
id: my_module
name: My Module
version: 0.1.0
category: attribute
description: Lists the most frequent activities in the log.
license: MIT

requirements:
  event_log:
    log_model: case_centric
    required_columns: [case_id, activity, timestamp]
    min_events: 1

provides: [my_module.top_activities]
consumes: [log.imported]

dependencies:
  python:
    requires-python: ">=3.12"
    inherit: [pandas]
PieceEffect
id in both filesThe loader matches them; a mismatch fails the load with a clear error.
@route.getRegistered under /api/v1/modules/my_module/top-activities, with auth and OpenAPI types handled.
@job under @on_eventThe precompute becomes a job the platform runs when a log first needs the module: it appears in the jobs UI and drives the page's progress state.
ctx.cacheResults stored per (log_id, module_id), surviving restarts.
requirements.event_logThe card is only offered on case-centric logs with those columns.

Startup sequence#

  1. Discovery — the folder is found by scanning modules/*/manifest.yaml, or from a mate.modules entry point.

  2. Validation — the manifest is parsed and the dependency graph built; a cycle or a missing hard dependency logs a named error and drops that module plus its dependents, so one broken module never bricks the boot.

  3. Materialisation — the dependency block is hashed; if it changed, uv venv + uv pip install runs in the folder and the frontend is bundled into .dist/.

  4. Import and instantiate — module.py is imported under a private namespace with the module's own site-packages; your class is constructed exactly once per process.

  5. Binding — routes mounted, event handlers subscribed, job handlers registered, capabilities added to the registry.

Development loop#

TaskHow
Change a handlerSave; in ENV=dev the watchdog reloads the module in place.
Change requirements or dependenciesSave; gating re-evaluates, and a changed dependency block triggers uv sync for that module.
Change the frontendThe web dev server watches modules/** and rebuilds the bundle; hard-reload the page.
Debug a load failureAPI log for the traceback, then the module detail page for its log tail.

Constraints#

  • Do not import from apps/api/* or apps/web/* — the SDK is the contract.
  • Do not construct log paths yourself; the ones from ctx are ownership-checked.
  • Do not keep state on self; the instance is shared and jobs run concurrently.
  • Do not call asyncio.run inside a handler.