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.
On this page
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 |
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,/tasksor/wheel. - 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 |
| Training ๐ | 20 | tasks, assignments, overdue sweep |
| Trance ๐ | 25 | scores, sits, checks |
| Kit ๐ฆด | 30 | inventory, bouts, consumables, badge rules |
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. 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 also ships inside the app, at /guide โ so your own instance always serves the guide for the version you are running, with the internet unplugged. Get Latch ยท Something wrong here? Tell me.