Guide βΊ Modules
How modules work
The Handler, the wheel and training are plugins discovered by directory. Here is what a module can do, the rules it must keep, and a skeleton for writing your own.
Latch's core is small: sessions, the event log, the derived clock, undo, media, export, and
the pages that show them. Everything with personality is a module β a Python package under
app/latch/modules/<name>/ that the app discovers by listing the directory. There is no
registry file to edit and no manifest to keep in step. Drop a package in; it is live on the
next start.
What a module contributes
| Hook | What it is for |
|---|---|
nav |
a link in the top bar |
router |
its own pages and POST routes (FastAPI) |
templates/ |
its own Jinja templates, found by glob |
seed(conn) |
first-run data β the default prompts, segments, tasks |
dashboard_card(conn, state, now) |
a card on the dashboard, plus an optional badge for its action button (due, 3 open, ready) |
settings_keys |
the keys it wants editable on /settings; the core renders the form and saves them |
settings_panel(conn) |
its editors β a prompt library, a segment table β rendered in its /settings section |
progress_panels(conn, now) |
charts it contributes to Progress β Trends. A module owns its own data, so the check-in score lines are the Handler's and the steps/sleep/mood overlay is health's |
insights(conn, now) |
observations it contributes to Progress β Insight, under the same floors as the core's own |
on_session_start / on_session_end |
react to a lock beginning or ending |
on_event(conn, event) |
react to any event, from any module |
on_tick(conn, now) |
the scheduler, every 30 seconds β sweeps, audits, expiries |
egress |
one sentence saying what this module fetches from the network, if anything. Rendered on /settings above the module's own knobs and named in the System line. Empty means it declares none. See Privacy β a declaration is a statement, not a cage |
A module bug never takes the core down: hooks run inside a guard that logs and moves on β a module whose chart or observation raises loses that chart, and nothing else on the page.
Every one of these is asked for, never registered: the core walks the modules the same way discovery walks the directory, so a module's charts, badges and observations arrive with the module. A central list would be a second source of truth that goes stale exactly when someone adds one, and deleting a package would leave it behind.
The three rules
- Never touch the clock. A module changes the game only by emitting events through the
core (
core.service.adjust,freeze,emit). The timer is derived from the log; the log is append-only; a module that wrote tosessionsdirectly would be a lie the clock could not see. - Editors live on
/settings. The play pages are for playing. There is a test in the repository that fails if an editor table or a rule form renders on/,/handler,/tasks,/wheel,/kitor/habits. (A short add one form on a play page β a new kit item, a new habit β is fine; the full editor with matchers and colours is not.) - Facts on the API, prose on the page. If your module exposes anything on
/api/v1, it goes through the allowlist test, and anything a person typed stays off the event feed. See Privacy.
The shipped modules
| Module | Order | What it owns |
|---|---|---|
| Handler πΎ | 5 | prompts, entries, the daily audit |
| Wheel of Fortune π‘ | 10 | segments, spins, cooldown |
| Health π | 15 | readings, rules, recovery days |
| Habits π― | 18 | logs, the timer, calendar feeds, the coach's verdicts β declares egress |
| Training π | 20 | tasks, assignments, overdue sweep |
| Trance π | 25 | scores, sits, checks |
| Kit 𦴠| 30 | inventory, bouts, consumables, badge rules |
| Playsets π | 40 | installed content, events, the day's scheduled training, badge rules from playsets. The engine is core; this is the door |
Order sets the nav position and the card order.
A skeleton
# app/latch/modules/dice/__init__.py
from .module import module # noqa: F401
# app/latch/modules/dice/module.py
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from ...core.registry import Module
from ...db import get_db
from ...web.templating import render
router = APIRouter()
@router.get("/dice", response_class=HTMLResponse)
def page(request: Request, conn=Depends(get_db)):
return render(request, "dice.html", {"rolls": []}, conn=conn)
class DiceModule(Module):
name = "dice"
title = "Dice"
icon = "π²"
order = 40
router = router
nav = [("Dice", "/dice")]
settings_keys = [("dice.sides", "Sides on the die", "6")]
def dashboard_card(self, conn, state, now):
return {"template": "dice_card.html", "ctx": {}, "badge": ""}
def settings_panel(self, conn):
return {"template": "dice_settings.html", "ctx": {}}
module = DiceModule()
Put dice.html, dice_card.html and dice_settings.html in app/latch/modules/dice/templates/.
Read a setting with get_setting(conn, "dice.sides", "6"). Change the game with
core.service.adjust(conn, -600, reason="β¦", module="dice") β never with SQL on the clock.
Ideas that fit the shape
Dice as a first-class module, scheduled random events, hardcore mode rules, a
Matrix bot. A module that needs the network β a bot, a feed, a share layer β sets egress
to one honest sentence and does nothing until the player configures it. If you build one, the contributing page explains how to
propose it.
A module can also contribute badges: return a list of core.badges.Rule from
badge_rules(conn) and the engine handles awarding, idempotency and the period rule. See
Kit for what that looks like in practice.
This page is docs/modules.md in the repo. Spotted a mistake? Tell us how to report it β