Latch

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

  1. 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 to sessions directly would be a lie the clock could not see.
  2. 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 or /wheel.
  3. 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.