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/
├── 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#
Copy a small, complete example —
modules/performance_over_timehas a panel and a widget.Terminal cp -r modules/performance_over_time modules/my_moduleRewrite the identity in
manifest.yaml:id,name,version,description, and shrinkrequirementsto what you need. Theidis lowercase snake_case and globally unique.Rewrite
module.py: setid = "my_module"on the class (it must match the manifest), delete the handlers you do not need, keep one route.Start the platform with hot reload.
Terminal make devImport a log, open the module grid, click your module.
A minimal module that computes a result#
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")
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]
| Piece | Effect |
|---|---|
id in both files | The loader matches them; a mismatch fails the load with a clear error. |
@route.get | Registered under /api/v1/modules/my_module/top-activities, with auth and OpenAPI types handled. |
@job under @on_event | The 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.cache | Results stored per (log_id, module_id), surviving restarts. |
requirements.event_log | The card is only offered on case-centric logs with those columns. |
Startup sequence#
Discovery — the folder is found by scanning
modules/*/manifest.yaml, or from amate.modulesentry point.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.
Materialisation — the dependency block is hashed; if it changed,
uv venv+uv pip installruns in the folder and the frontend is bundled into.dist/.Import and instantiate —
module.pyis imported under a private namespace with the module's ownsite-packages; your class is constructed exactly once per process.Binding — routes mounted, event handlers subscribed, job handlers registered, capabilities added to the registry.
Development loop#
| Task | How |
|---|---|
| Change a handler | Save; in ENV=dev the watchdog reloads the module in place. |
| Change requirements or dependencies | Save; gating re-evaluates, and a changed dependency block triggers uv sync for that module. |
| Change the frontend | The web dev server watches modules/** and rebuilds the bundle; hard-reload the page. |
| Debug a load failure | API log for the traceback, then the module detail page for its log tail. |
Constraints#
- Do not import from
apps/api/*orapps/web/*— the SDK is the contract. - Do not construct log paths yourself; the ones from
ctxare ownership-checked. - Do not keep state on
self; the instance is shared and jobs run concurrently. - Do not call
asyncio.runinside a handler.