Skip to content

Architecture

Heinzelmännchen is a multi-headed modulith: one process, one kernel, many modules, several heads.

flowchart LR
    subgraph Heads["Heads (views)"]
        CLI["CLI head<br/>hz ..."]
        MCP["MCP head<br/>hz system serve mcp"]
        REST["REST head<br/>hz system serve api"]
        Web["Web head<br/>hz ui"]
        Daemon["Daemon head<br/>hz system serve daemon"]
    end
    subgraph Kernel
        Discovery["Discovery<br/>heinzel.modules + entry points"]
        Registry["Registry<br/>pluggy"]
        Injector["Injector<br/>by type"]
    end
    subgraph Modules
        System["system (built-in)<br/>├ agent ├ changelog ├ config<br/>├ daemon └ doctor └ plugin"]
        Heinzel["heinzel (built-in)<br/>your Heinzelmännchen: contracts → daemon jobs"]
        Clock["clock · github · atlassian · google · zoom<br/>(first-party plugins)"]
        Ext["heinzel-*<br/>(external plugins)"]
    end
    Discovery --> Registry
    Modules -- "advertise lets" --> Registry
    CLI -- "OperationLets" --> Registry
    MCP -.-> Registry
    REST -.-> Registry
    Web -.-> Registry
    Daemon -.-> Registry
    CLI -- "call" --> Injector

Concepts

Module. A package whose __init__.py declares module = Module(name, summary). Packages below it that declare their own module are its submodules: heinzel.modules.system.changelog is the submodule system.changelog. Built-in modules live in heinzel.modules; plugins point an entry point in the heinzel.modules group at their root package, and the kernel walks it the same way.

Let. A typed declaration a module advertises. The kernel stores lets without interpreting them; each head picks the kinds it understands.

Let Declared with Used by
OperationLet @module.operation every head: CLI command, MCP tool, REST endpoint
ServiceLet module.service(Type) the injector: provides Type wherever it is asked for
SettingsLet @module.settings on a ModuleSettings class the injector: settings from the module's TOML table and HZ_<MODULE>__*
InfoLet @module.info hz system info
HealthLet @module.health returning Checks hz system doctor
CredentialLet module.credential(name, purpose=...) Credentials (injected) and hz system credential with its doctor check (ADR 0030)
SetupLet @module.setup(required=False), asking through an injected Prompter and writing through ConfigEditor and Credentials the CLI head: hz setup [MODULE] (ADR 0032)
PlaybookLet module.playbook(summary, assets=..., templates=..., defaults=...): asset types with discovery hints, template contracts, recommended settings heinzel: hz heinzel playbooks, discover, new --template; the setup step it declares (ADR 0037)
LayerLet module.layer(parent=..., site=..., roles=...): the plugin sits in a company's tree, under its parent layer and at a site layer the kernel: Kernel.layers() checks the tree, Kernel.layer_stack() stacks your layers general to specific, afresh on every call; system.layer: hz system layer list, show, doctor (ADR 0040)
SkillLet module.skill(path): a SKILL.md directory or a directory of them, checked at import (frontmatter, sizes, text only, no links, no secrets) system.skill: hz system skill list, show, export; the read-only Skills service for Pydantic AI turns (skill_tools) and the MCP head's prompts and resources (ADR 0061)
JobLet @module.job(schedule="0 9 * * MON") hz system daemon tick, run by launchd or systemd (ADR 0020)
ProviderLet @module.provides("tracker", modes={"read"}, hosts=[...]) on an adapter class; hosts= takes a function of the module's settings when they decide the hosts the kernel's capability bindings and hz system capability (ADR 0028)
RequirementLet Module(..., requires=["tracker:read"]) (read only); operations and jobs take requires=[...], and a read-only operation that degrades without its own takes optional=True the kernel's capability handles and the doctor check of hz system capability (ADR 0028)

New kinds of lets (watchers) extend Heinzelmännchen without changing existing heads; see ADR 0010. Contracts are data, not lets: the heinzel module reads them and provides CompiledJobs, which the kernel compiles afresh whenever the daemon lists its jobs and runs beside the declared JobLets; each compiled job reads through Kernel.handle_for as the requester its contract declares (ADR 0021).

Kernel. Discovers modules, registers them with a pluggy plugin manager (blocking disabled ones), hands out lets by kind with their owning module, and injects dependencies by type: Kernel, Settings, Config, every type a ServiceLet provides and every SettingsLet model, plus Installation (the uv tool environment) and Progress (head-provided progress reporting: operations never print). Services are singletons per kernel and may depend on other services. A capability protocol (Tracker, Documents, Chat, Meetings, Calendar, Mail, Directory, ...) is injected as a handle on the adapter bound in [capabilities], limited to the modes the asking operation, job or module declared; write reaches only publish operations (read_only=False); adapters pass every string they return through clean_text. Several adapters may provide one capability and the binding picks one; an adapter may build on another capability (meetings from summary emails, through mail) (ADR 0028). The org chart is the capability directory: a plugin's directory of record when one is bound, otherwise the core's one-on-ones adapter (system.directory), which guesses reporting lines from recurring 1:1s in the bound calendar and marks each as a guess (with no calendar bound the draft is empty and says why); setup steps and roster drafting call draft_reporting_lines. Agent sessions are the capability agent-runner; core ships its Claude Code and generic command adapters in system.runner (ADR 0029). The team graph is the capability team-graph, served by the core's hz-graph adapter (graph), so a contract (the graph keeper) can read it; graph builds it from the kernel's TeamPromises (provided by workflow) and TelemetryLog (provided by telemetry, command hz report), whose entries are TelemetryEntry (ADR 0054). Every authenticated human-facing head notes that hz was used today through the kernel's Presence (provided by system.state) before it runs an operation; system.state keeps that and the hz version as local append-only streams and its daemon job pushes them, through the kernel's hardened GitRunner (shared with team), to the machine's writer branch hz/<handle>/<machine> of the person's own state repository, fast-forward only (ADR 0081).

Head. A view that boots the kernel and exposes operations (ADR 0019). All heads share one exposure rule (heinzel.heads._exposure): the same path, public parameters and heads= restrictions. The MCP head makes each operation a tool (system_doctor), the REST head an endpoint (GET /system/doctor, OpenAPI at /docs). An operation declared private=True is omitted from public REST and network MCP and requires trusted identity before dependency injection or execution. hz system serve api --private exposes those operations behind a server-scoped expiring bearer, written once to an owner-only file. A server restart retires its old credentials; credentials expire after twelve hours, and their raw values never appear in generated tool results or status output. The client sends it in the Authorization header; it is never a URL parameter or an approval proof. Private API OpenAPI remains authenticated, with CDN-backed interactive docs disabled. CLI, stdio MCP and paired web use the same immutable CurrentUser context. New private mutations and private streams take a flat JSON object of public arguments, reject unknown fields, and keep message/attachment/proof input out of URL query strings. Existing operations retain their wire shape. Read and write scopes are enforced in the kernel; services still check each resource's owner. Installation identity and default API credentials use user-scope settings, so a project cannot redirect them. See ADR 0068; human-proof issuance and consumption remain proposed. The web head (hz ui, heinzel.heads.web) serves a React app (web/, built into the package) at / and the API head's endpoints for head web under /api, on localhost only, behind a token made per process and sent as Authorization: Bearer; every response carries a strict CSP. Operations hidden from the unauthenticated REST head because they read your profile or your systems list web in heads=; nothing that spends money is exposed but hz system runner ask and the chat (hz chat send), which have their own caps (ADR 0039, "a web head: a React app served by the API head", proposed). The web head also streams any of its operations as server-sent events at /api/stream/<path> (heinzel.heads._stream): what the operation emits through Progress.emitter(), then its result. The chat (system.runner.chat, ADR 0045, proposed) runs the agent runner with hz's operations as tools from the MCP head in chat mode (hz system serve mcp --chat <session>, stdio only): read operations run, every other one goes to the kernel's ActionGate as a proposal that only the person's hz chat confirm, on the CLI and web heads only, runs. The typed client in web/src/api/ is generated from the OpenAPI document by make fix and checked by make check. The daemon head (hz system serve daemon) calls the hz system daemon tick operation in a foreground loop; installed, launchd or systemd start that tick every few minutes instead (ADR 0020). The CLI head turns a module into a command group and an operation into a command; parameters without defaults become arguments, others options, and Field(description=...) becomes help text. Every operation's command gets --json. A module whose only operation shares its name and has no submodules collapses into a single command (hz system changelog); beside other operations, the one named like its module is what the group does by itself (hz system expert, next to hz system expert on), in every head.

Expert mode. Every operation declares its audience: everyday (@module.operation(audience="everyday")) or expert, the default. Outside expert mode ([hz] expert, user scope; hz system expert on|off) the CLI's help and shell completion and the web app's navigation and search list only everyday operations, and hz --help ends with how many are hidden. Hidden is never disabled: every command runs and answers --help, and MCP and REST list every operation (ADR 0041).

Renames. A renamed module keeps its old name working for one minor release (ADR 0051): Module(..., renamed_from="workflow") reads the old settings table and HZ_<OLD>__* variables beneath the new ones and lets the daemon run a job asked for by its old id; @module.operation(aliases=["workflow run"]) keeps an old command path as a hidden CLI command that names the new one on stderr, and as an MCP tool marked deprecated.

Configuration. Layered TOML files (site, user, project hz.toml, $HZ_CONFIG) under environment variables; the [hz] table holds kernel settings, [capabilities] the capability bindings, [<module>] tables module settings (ADR 0012). Settings that decide what runs or where data goes (the bindings, the agent runners' executables and tools) are ignored in a project hz.toml, and doctor names any it sets (ADR 0028).

The command tree

The top level belongs to the team's work: every module a plugin brings (and future built-in workflow modules) sits there. Everything about Heinzelmännchen itself lives under one administrative namespace, hz system (ADR 0018), except hz setup, which runs every module's setup step (ADR 0032), and hz join, the invite line that joins a team:

hz --version
hz join URL                                join a team: who you are, the team, what is still missing (CLI only)
hz setup [MODULE]                          every module's setup step, or one module's, wizard style (CLI only)
hz ui                                      the web app on localhost, opened in your browser
hz system info | modules | doctor | changelog | release | update
hz system expert [on | off]                every command in help, or just the everyday ones (ADR 0041)
hz system config  show | get | set | unset | init
hz system plugin  list | add | remove | new | docs show | docs build
hz system layer   list | show
hz system agent   skills | install | uninstall
hz system skill   list | show | export | unexport   skills for AI; export hands chosen ones to Claude Code (ADR 0061)
hz system daemon  install | uninstall | status | logs | tick | pause | resume | jobs
hz system notify  list | ack | test | install
hz system serve   mcp | api | daemon
hz system state   status | push | reset     your machine's presence and versions, to your own repository
hz report <verb> | sync                    what a Heinzelmännchen did, for the team graph (ADR 0054)
hz graph                                  are the team's promises kept, and where the flow waits
hz <module> …     work: plugin modules and built-in workflow modules

The kernel is the core: a library without commands. system is the one module that ships with the core distribution and can't be disabled; it operates the kernel. Every other module is built like a plugin.

Built-in modules

Generated, so it can't drift: MODULES.md lists every module with its operations in every head, its settings and its health checks.

Flow of a command

  1. hz system info starts heinzel.heads.cli:main, which calls heinzel.kernel.boot().
  2. Boot loads the configuration files and the kernel settings; discovery walks heinzel.modules and every heinzel.modules entry point; broken plugins are reported, not fatal.
  3. The kernel registers modules (minus HZ_DISABLED_MODULES) and their ServiceLets.
  4. The CLI head builds the Typer tree from OperationLets.
  5. Typer parses arguments; the head calls kernel.call(operation, **arguments), which injects SystemService.
  6. The result (a pydantic model) is rendered by the module's render= hint, or printed as JSON with --json.

Plugins

A plugin is a Python distribution with [project.entry-points."heinzel.modules"] pointing at its root package. It depends on hz and imports only its public API (from heinzel import Module, HeinzelError, ...). hz system plugin add <spec> shows what it would install, pinned to a release or commit, and with --confirm reinstalls the uv tool environment with --with <requirement>, so plugins survive uv tool upgrade heinzel (ADR 0006, ADR 0047); it takes PyPI and https git sources only, and hz system plugin develop <path> adds a local checkout. New plugins are scaffolded with hz system plugin new, from the copier template in this repository (ADR 0007).

Every plugin, first-party or external, is named the same way (ADR 0025): distribution and repository heinzel-<name>, root package heinzel_<name>, entry point <name> in the heinzel.modules group. hz system plugin add <name> resolves a catalog name from project metadata. Public implementations live in heinzel-plugins, with independent locks, environments, versions and releases. External/private repositories use the same SDK and discovery rules. The standard app profile includes Clock, Resource, Tailscale and the Heinzelmännchen theme; minimal core has no plugin dependencies. Resource is the only built-in module extracted in 0.9; the theme plugin also owns the former bundled soundscape content. See ADR 0094 and ADR 0095.

Layering

Enforced by import-linter (make lint):

  • heinzel.kernel must not import heinzel.heads or heinzel.modules.
  • heinzel.modules must not import heinzel.heads.
  • heinzel.heads must not import heinzel.modules — heads only know what the kernel discovered.

Plugin-owned views

Clock demonstrates a complete plugin-owned feature: right panel/globe, footer clock, header action, sidebar ticker, menu/Settings and CLI/MCP/API operations live in its project. Its panel uses ?panel=clock:world-clock; core consumes only the generic view and contribution contracts. Asset attribution and implementation details live with the Clock plugin.

The extension guide documents ABI 1, owned page routes, local bundles, fallbacks and shared interface contracts. A required interface derives from ServiceContract; missing implementations disable dependent operations without exposing the interface as a user input or making unrelated heads unusable.

Phone overlays

The web head sizes dialogs and positions footer popovers against the visual viewport, including keyboard and zoom changes. Floating controls respect device safe areas; touch form fields use at least 16px text to avoid focus zoom. web/e2e/mobile-responsive.mjs checks phone portrait/landscape layouts, dialog and footer bounds, navigation, keyboard-sized heights and zoom. The collapsed sidebar keeps direct play/pause; see ADR 0088.

The workspace uses descriptor-driven tabs for Chat, Terminal and World Clock on desktop and phone. Hidden contents stay mounted. On phones, the World Clock, Chat and Terminal header actions scroll to their panels, and Page scrolls back to the main screen. Reduced motion uses an immediate transition. The header and footer stay visible while scrolling, except in the full-screen terminal. New panels use the same renderer rather than adding another stack or viewport rule; ADR 0089 records the boundary.

On iPhone and iPad, embedded YouTube volume belongs to the device's volume buttons. The sound picker explains this instead of offering a slider or typing ducking; layered Web Audio soundscapes keep both. Stream pause, meeting mute and mute during voice input use the player's pause/mute commands rather than volume fades.

Notification tests report the browser running the app. Only loopback desktop pages also test and offer repairs for the server's native notifier. Remote pages never open the server's notification settings or claim its pop-up reached the phone. The test shows a browser notification and checks that delivery before claiming success. iPhone/iPad tabs explain Share → Add to Home Screen; the installed app can request permission. The app must remain running to receive notices: it has no background Web Push service (ADR 0046).