🐾 Latch ?

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

  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, /wheel, /kit or /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.)
  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
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 β†’