0049: Heinzelmännchen are durable agents: one session each, owned by hz¶
Context and Problem Statement¶
Today a contract is rendered by "one bounded agent session per run" (ADR 0010,
ADR 0021 §6): every run starts from nothing, gets the inputs as data and leaves a draft, a
.record.json and a receipt. Nothing carries over but the files. The chat (ADR 0045)
keeps its own sessions, apart from the contracts it talks about. The maintainer decided the product direction
(2026-10-24, design doc): a Heinzelmännchen is a durable agent with a stable id and one continuous session for its
whole life; every wake-up (its schedule, a source that changed, a source newly discovered, the user asking) is another
turn in that session. It acts with its user's identity and logins, drafts in place with a DRAFT tag, and does nothing
beyond without approval. It has exactly one contract (what it keeps true, from which sources, by when, who approves),
written in plain language in the artifact's Heinzel tab or in chat, compiled once into a precise form and approved;
contracts evolve by amendments, proposed by the Heinzelmännchen or the user, approved and versioned, and every draft
records the contract version that produced it. After each approval it diffs draft against approved text and decides on
its own which edits are lessons, visible to the user as "what I learned from you"; it never changes its own
permissions. A user has hundreds of them, most asleep and cheap to wake. Calling Claude Code headless leaves the
transcript with Claude Code, not with the Heinzelmännchen. What is a Heinzelmännchen's session, where does it live, how
does it survive crashes and sleep, what of it leaves the machine, and how do hundreds stay cheap?
Decision Drivers¶
- Tenet 4 (humans own, with lineage): a draft must say which contract version made it and who approved it; a lesson must be visible and reversible; the session is the lineage of one asset, owned by its user, not by a model vendor.
- Tenet 8 (open and independent): the transcript's format belongs to hz, so any model or runner continues it (ADR 0029; the chat already resends its transcript for that reason, ADR 0045).
- Done, not nagged (tenet 3) and "hundreds per user": a Heinzelmännchen asleep must cost no process, no open file and no model call; waking one must cost one query and one bounded turn.
- Laptops sleep and processes die mid-turn (ADR 0020): a turn resumes where it stopped and never places a draft twice or spends twice (the "durable runs" idea of the research note of 2026-10-24).
- Nothing is sent home (ADR 0027); what goes to the team's repository is lineage without content (ADR 0021 §13, the never-commit list of the "core and skills" note).
- Python decides whether, when, where, as whom and at what cost; the model decides what (the "core and skills" note, §2). Pruning, summarising and the choice of what a turn sees are deterministic, hz's job, not the model's.
Considered Options¶
- One append-only JSONL session per Heinzelmännchen (a small spine kept for life, turn bodies that expire), a derived SQLite index, checkpoints per turn step with idempotency keys; hz owns the session and builds every turn's context (chosen)
- Resume the runner's own session (Claude Code
--resume <session-id>, its JSONL under~/.claude/projects): no format of ours, no versioning, no pruning we control, per machine and per vendor; the transcript leaves hz when the runner changes. ADR 0045 rejected it for the chat for the same reason. - Stateless runs plus "the last approved text and the diff in the packet" (ADR 0038 §4, the status quo): it carries corrections forward but has no identity, no lessons the user can see and undo, no amendments, no conversation; the chat about a contract and the contract's runs stay two things.
- One SQLite database as the session store (Pi Durable's harness: conversation entries, documents and tasks in one committed store): atomic and queryable, but a binary file the user can't read, diff or hand to another tool, and a schema migration on every change. Kept for the index, which is derived and can be rebuilt; the record of what happened is text.
- Pi Durable itself (the pi project's own durable harness,
@earendil-works/pi-durable, in the same monorepo as the pi coding agent, earendil-works/pi: checkpointed tasks in SQLite, idempotent submissions, forkable conversations): three weeks old, experimental ("the API changes without notice"), TypeScript, and it owns the agent loop. We borrow its rules (checkpoint every phase; a side effect reruns after a crash only if declared replay-safe; idempotency keys on every write; memoised approval decisions) and build them in Python on the ledger we have. Attribution amended 2026-10-24: this option first called it "Earendil's TypeScript library" as if it were apart from pi; pi (pi-ai,pi-agent-core,pi-coding-agent) and Pi Durable are one project, the coding agent being the non-durable loop and Pi Durable its durable restatement (announcement, spec). The verdict stands and its spec strengthens it: one process owns a storage at a time, the JSONL backend keeps history in memory, the API has no stability promise. Statements 12–21 below borrow pi's two queues and Pi Durable's replay and parking rules. - OpenProse Reactor as the wake runtime (added 2026-10-24; openprose/reactor,
the alpha reference harness that keeps a set of
*.prose.mdcontracts true: a compile step lowers contracts to a subscription graph, canonicalizers and validators, and a deterministic reconciler decides skip, render, commit or propagate from fingerprints): rejected as a runtime, because it renders through a vendor agent SDK with a provider key (against ADR 0035 and ADR 0030), lets a model compile what is material (hz keeps that judgment in Python, ADR 0021), ships a flat 60-second poll, has no actor or timestamp on its receipts and makes no compatibility promise. Its reconciler's rules are borrowed (statements 15 and 20): a schedule is a recheck, not a render; a skip costs nothing and is recorded; stage the input before the wake; fingerprints per facet; single flight per node with coalescing; wakes ordered by graph height; downstream wakes only when the approved output moved (ADR 0052 §7); cost rolled up by wake cause. - A central service holding sessions (a hosted database, a vendor's "agent memory"): against ADR 0027 and the no-server design of ADR 0020; identity would stop being the user's own logins.
- Receipts as the session (grow ADR 0020's hash-chained receipts until they hold the conversation): receipts are lineage without content and are kept forever; a conversation holds content and must expire. Two things, one chain each; the spine references receipts.
Decision Outcome¶
Chosen option (proposed): a Heinzelmännchen is a stable id, one contract with versions and one session hz owns: an append-only JSONL spine kept for its life, turn bodies that expire, a derived SQLite index that lists hundreds at once; every wake is a turn whose steps are checkpointed with idempotency keys; lessons and amendments are events in the same session; what leaves the machine is lineage without content. Checkable statements follow; the turn engine is ADR 0050, the module and its commands ADR 0051.
- Identity. A Heinzelmännchen has an id (
hz-and a 26-character ULID, minted at creation, never reused, the directory name and the key everywhere), a name (a slug like today's contract names, unique per namespace, shown to people, renamable) and a namespace (meorteam, ADR 0021 §10). It belongs to one user: the person who joined and whose logins it uses. "User" is the word for someone who has joined (the maintainer's decision);Personstays the directory's SCIM record of the org chart (ADR 0024). It acts as its user through the vendor CLIs (ADR 0035) and the user's own model login or key (ADR 0030); there is no service account and no central server. A draft tag names the id and the contract version (ADR 0038 §2). - Exactly one contract, in versions. The contract is ADR 0021's
.prose.mdtext (frontmatter underhz:, an OpenProse-compatible body); nothing about its format changes here. Version 1 is compiled from plain language (a sentence in chat, the artifact's Heinzel tab, a playbook template, ADR 0037) and shown; the user approves it, and only then does the Heinzelmännchen wake on its own. Later versions come from amendments: a proposed text, by the Heinzelmännchen (from what it learned, a source it discovered, a question it couldn't answer) or by the user (in chat or as a file edit), approved withhz approve, compiled, numberedn+1. Every version is kept (contract/v<n>.prose.md, its compiled form and its intent fingerprint, ADR 0021 §8). An amendment the Heinzelmännchen proposes may change the body and may narrow the frontmatter (fewer inputs, a lower cap, a later deadline); one that widens it (requires,inputs,publishes,agent.max_usd,schedule,approvers) is refused by the compiler (HZC050) and must be written by the user. That is how "it never changes its own permissions" is enforced in code, not in a prompt. Lessons (6) never touch the contract. - One session, two kinds of files. Under
<data dir>/heinzel/<id>/: session.jsonl, the spine: one JSON object per line, append-only, kept for the Heinzelmännchen's life. Each line is an envelope{v, seq, ts, prev, turn, kind, key, data}:vthe envelope version (1),seqa counter,tsUTC,prevthe SHA-256 of the previous line (the chain of ADR 0020 §8: it detects truncation and hand edits, not tampering),turnthe turn id,kindthe event kind,keythe idempotency key when the event is a step (5),datathe kind's payload. Kinds are additive; a kind whose payload changes incompatibly gets a new name (draft.placed.2), readers skip kinds they don't know, andvchanges only if the envelope does. The spine holds no draft text, no fetched input and no model message: it holds ids, hashes, sizes, costs, dispositions and the short texts the user is meant to read (a lesson, an amendment, a question, a summary, a report line). Budget: a turn adds about a kilobyte; a Heinzelmännchen with a daily schedule stays under a megabyte for years. Amended 2026-10-24: new kinds, the frozen vocabulary and migration on read are statement 18.turns/<turn>.jsonl, the body of one turn: the messages exchanged with the model, tool arguments and results, the inputs as fetched, the draft's text, a handoff's transcript (ADR 0050). It is what the.record.jsonis today and expires like it:[heinzel] retention_days(default 90, 0 keeps forever; a projecthz.tomlcan't set it) after the turn, deleted by the jobheinzel:pruneand the operationhz heinzel prune, never by a read (ADR 0021 §13's rules: only files hz wrote,O_NOFOLLOW, idempotent). The spine references every body by turn id and content hash, so a body that is gone is a gap the user can see, never a break in the chain.- Files are private (0700/0600) and written atomically with fsync, like receipts.
- Turns and their causes. Every wake is a turn with one cause, recorded in
turn.started:schedule(the contract's slot, ADR 0020 §4),source-changed(an input's fingerprint differs from the one the last turn saw, ADR 0021 §8),discovered(a playbook hint or the user's artifacts turned up a source the contract doesn't name),asked(the user, in chat or withhz heinzel wake),approval(a placed draft was approved or rejected, ADR 0038 §3: the learning turn, 6) andcreated(the first turn, which compiles version 1). A turn is a fixed sequence the engine walks and the model fills: gather (inputs through the capability handles the contract declares, as today) → think (model and tool steps, ADR 0050) → outcome (a structured result: a draft, lessons, an amendment, a question for the user, or nothing to do, with a report line) → act (hz, in code: place the draft with its tag, record the lesson, file the amendment, ask the question) → finish (turn.finished: dispositiondrafted | asked | learned | proposed | skipped | failed | abandoned, cost, duration). The chat with a Heinzelmännchen is the same session: a message is a turn of causeasked, the sidebar chat with one selected is its session, and the general chat of ADR 0045 stays for everything that is not about one Heinzelmännchen. Which turns the next turn sees is hz's choice (8), never the model's. Amended 2026-10-24: a turn may also wait (statement 12), has the further causesretryandstaleand may carry several causes at once (13), takes chat messages while it runs (14), andskippedhas sub-reasons (15). - Crash safety: a checkpoint per step, an idempotency key per effect. Each step appends its event before the next
starts. A wake has a key:
<id>@<slot>forschedule,<id>@<input>:<fingerprint>forsource-changed,<id>@<request id>forasked(the chat's message id); a tick that finds aturn.startedwith that key and noturn.finishedresumes that turn at its last checkpoint, one with aturn.finishedskips it (the daemon's idempotency rule of ADR 0020 §4, now per turn). Reads and model calls are replay-safe: rerunning them costs money within the cap and nothing else, so an interrupted think step is rerun from the last recorded model message. Effects are not:place_draftcarries the key<id>@v<contract version>@<slot>@<asset>and the adapter looks for a tag with that key before writing (ADR 0038 §2's methods gain the key), a question is asked once per key, a notice (ADR 0043) once per key. A turn that overran its timeout isabandonedand never resumed, as a job is today. The SQLite index (7) is derived: it is updated after the spine line is durable, carries theseqit has seen, and a mismatch on open rebuilds it from the spines (hz heinzel reindexdoes it on demand). Nothing a crash can leave behind needs repair by hand. Amended 2026-10-24: effects are journalled in a ledger, tools declare whether they are replay-safe, and a turn resumed too often isabandoned(statement 16); a resumed turn runs under the versions it started with (17). - Lessons. When an
approvalturn finds the draft approved, the model gets the draft as placed and the text as approved (both from the body and the adapter) and decides, as a structured outcome, which edits are lessons: a sentence each ("Priya moves risks above the summary", "no adjectives in headings"), with the diff hunks they come from. Each is alesson.recordedevent; the active lessons are part of every later turn's instructions, after the contract and before the inputs. The user sees them as "what I learned from you" (hz heinzel show, the page) and can dismiss one (lesson.dismissed; it is kept, marked, and never shown to the model again). A lesson is advice about how, never a permission: it can't add an input, a target or a tool, which is why it lives outside the contract (2). The approver, the diff and the share of words changed are recorded as ADR 0038 §4 says; "the next render gets the last approved text and the diff in its packet" becomes "the next turn has the lessons and, throughlast_approved, the text": the same lineage, now decided and explained. - The index: hundreds, listed at once.
<data dir>/heinzel/index.sqliteholds one row per Heinzelmännchen (id, name, namespace, state, contract version and fingerprint, assets, next due slot, watched inputs with their last fingerprints, last turn, pending draft or amendment or question, cost today) and one per turn, draft, lesson and amendment. Every row is derived from spine events, so the index is rebuildable and never authoritative.hz heinzel listfilters by artifact (asset ref), state and contract (name, version, a frontmatter key) in every head and never opens a spine. States:proposed(version 1 not yet approved),asleep,awake(a turn is running),waiting(a draft awaits approval, or a question or amendment awaits the user),paused(by the user, or a team contract whose pinned content changed, ADR 0021 §10) andretired(kept, never woken;hz heinzel retire, reversible). Amended 2026-10-24:waitingalso covers a turn that waits for a time or a change (statement 12); the row then carries what it waits on. - What a turn sees: deterministic context, hz's summaries. The model's instructions are, in this order: hz's
framing (what a Heinzelmännchen is and may not do), the contract's body at the current version, the skills that apply
(ADR 0050), the active lessons. Its messages are: the
last
summary.written, every spine event since it rendered compactly (turn, cause, outcome, report line), the last approved text when there is one, and this wake's inputs as data in<input>elements, framed as never being instructions (ADR 0021 §4). Bodies are never needed to continue, so expiry never costs continuity. When the events since the last summary exceed a budget ([heinzel] summary_after_turns, default 20, or 24,000 characters), the turn ends with the model writing a summary as a structured outcome ("what I know about this asset and this user"), recorded assummary.writtenand readable by the user. Pruning what the model sees is this rule and nothing else; no model decides what to forget. Amended 2026-10-24: the summary's schema, chaining and the turns kept verbatim beside it are statement 19; dismissed lessons and redactions apply ascontext.edit(18). - Scale: asleep costs nothing, a tick costs one query. An asleep Heinzelmännchen is rows in the index and files on
disk: no process, no open file, no model call. The daemon's tick (ADR 0020) asks the index
for what is due (slots since the last tick) and for the watched inputs, polls each source once per tick whatever
the number of Heinzelmännchen that watch it (one fetch per
(capability, method, args, window), conditional where the adapter can, hashed as today), and wakes those whose fingerprint moved. Wakes per tick are bounded ([heinzel] max_wakes_per_tick, default 10; the rest wait for the next tick, oldest due first), and so is spending: the contract'sagent.max_usdper turn,[heinzel] max_usdper turn whatever the contract says, and[heinzel] max_usd_per_dayacross all of a user's Heinzelmännchen; a turn that would cross a cap isskippedwith the reason and the user is told once a day. Discovery runs as a daily job over the playbooks' hints (ADR 0037) and the team's inventory, and wakes a Heinzelmännchen with causediscoveredat most once per source. Creation stays trivial: a sentence in chat, a template, a proposal by another Heinzelmännchen (hz approvecreates it), never a form. Amended 2026-10-24: "each source once per tick" becomes "each input when its recheck is due", inputs are staged before anyone wakes, wakes are ordered by graph height, and cost is rolled up by cause (statement 20); one writer per spine (21). - What leaves the machine. The session never does. For a team Heinzelmännchen, its receipt-grade events go to
the team repository's machine branch
hz/state(the "core and skills" note's option B: reviewed truth onmain, machine state on an orphan branch, leases as refs):turn.finished(cause, disposition, cost, the contract fingerprint, input fingerprints, the draft's hash),draft.placed(key, asset ref, hash),approval.observed(approver's roster handle, share changed, the approved text's material fingerprintoutput_fp, no text;output_fpamended 2026-10-24, ADR 0052 §7) andcontract.amended(version, fingerprint); they are the action log's entries (ADR 0052). Never a body, never a lesson or a summary (they are about one user's edits), never a name or an address (ADR 0024 §6). Those events are ADR 0020 §8's receipts for compiled jobs: a turn writes one receipt, the spine keeps its id, and the local ledger stays as it is. Who runs a team Heinzelmännchen is the note's rule: its owner's daemon, a named delegate's when the owner's hasn't ticked within two intervals, decided by a lease refrefs/hz/lease/<id>with an expiry and a fencing token thatturn.startedrecords. Leases, the orphan branch and the lint that refuses text in it are ADR 0020's remaining work, unchanged in shape; this record fixes what a session contributes to them. - The user's view.
hz heinzel list | show | chat | wake | new,hz drafts,hz approveand the MCP tools of the same names (ADR 0051); the web app's "My Heinzelmännchen" list and a page per Heinzelmännchen: the contract and its versions, the timeline of turns, the lessons, the pending draft, question or amendment, and the sidebar chat as its session.hz draftsis a projection over the index (everywaitingdraft with its place), not a store: the inbox as a view (ADR 0038 §8). Amended 2026-10-24:hz heinzel retry,resumeandstopjoin them (statements 13 and 14); a page or chat that opens mid-turn gets the current row and then deltas, never a replay (21).
Amended 2026-10-24: durable turns. Proposed by the architect from research the same day into the pi agent loop and Pi Durable (earendil-works/pi), the durable-execution engines (Temporal, Restate, DBOS, Inngest, Cloudflare Agents, LangGraph, the Pydantic AI integrations) and OpenProse Reactor (openprose/reactor); it is written to agree with the triggers and wake loop a later ADR will record. The engines converge on a few rules: journal before the effect and keep the effect outside the write; replay by stable name; at-least-once inside and a stable key at the edge; waiting is a stored condition plus a wake, never a blocked process; a human decision is memoised. Statements 1–11 hold; these are added.
- A turn can wait. Besides running (
awake) and finished, a turn may be waiting: the step that decides to wait appendsturn.waiting {step, until?, on?}, whereuntilis a UTC time ("give the reviewer until noon") andonone condition:{kind: source, input, fingerprint_not}(continue when the input moved),{kind: approval, key}(a named draft was approved or rejected) or{kind: answer, key}(a question of 18 was answered). Both together are a wait with a timeout, Restate's awakeable raced against a timer (Restate). A waiting turn holds no process, no open file and no lock; it is one row in the index's turn table with its condition. The Heinzelmännchen's own state (7) isawakewhile any turn of it runs, elsewaitingwhile a turn waits or an item awaits the user. Every tick asks the index for waiting turns whoseuntilpassed or whoseonmatched and resumes each at its checkpoint withturn.resumed {woken_by: timer | source | approval | answer | asked}, so the turn knows which fired;askedishz heinzel resume <name>, the user saying go on now (hz heinzel wakestill starts a new turn, 4). A wait withoutuntilgets[heinzel] max_wait(default 7 days) as itsuntil; the turn's timeout counts its running time, not its waiting. Other wakes don't queue behind a waiting turn: they run their own turns, and the waiting one resumes when its condition holds and no turn isawake. When one approval both resumes a turn waitingonit and starts theapprovallearning turn (6), the waiting turn resumes first. When another turn has meanwhile placed a draft for the same output, the waiting turn is finishedabandonedwith reasonsupersededinstead of resuming under an older version (17). Nothing sleeps and no timer object outlives a reboot, because none exists: the condition is data, as in Pi Durable'ssleeping(until)with the host'sonWakeand Cloudflare's alarms (Pi Durable spec, Cloudflare). A contract's deadline is a different thing: the index'snext_due, which marks a result late and never pauses a turn. - Causes
retryandstale, and several causes per turn.retryis a turn forked from another turn's checkpoint:turn.startednamesparent_turn,from_step(by default after gather, so inputs are not fetched again) and the user'shint; both bodies are kept and the parent's draft is markedsupersededin the index (hz heinzel retry <name> "shorter", or "try again, shorter" in its chat); its key is<id>@retry:<parent turn>:<request id>. It is pi's/forkand LangGraph's fork from a checkpoint without a tree in the spine, which stays a chain (pi sessions, LangGraph). It is not the contract'sretries, which rerun afailedturn within its slot under the same key (ADR 0021 §9).stalewakes a Heinzelmännchen when its approved output'svalid_untilpassed: the approval time plus the output'svalid_for(ADR 0021 §2), so Python, not the model, decides when a truth expires; its key is<id>@stale:<output>:<valid_until>, and a turn with causestalealways renders. An input'srecheckis only how often it is polled and never a wake by itself (20). Thesource-changedkey of (5) uses the hash of the watched facet, or the canonical hash of the whole fingerprint map when the input names no facet (ADR 0021 §8). Wakes due in the same tick coalesce:turn.startedkeepscause(the first, in the order of (4)) and gains the additive fieldcauses: [...]with one key per cause, so each cause's idempotency holds on its own and old readers still readcause;turn.finishedlikewise. - Chat while a turn runs: two queues, both journalled. pi's agent loop keeps a steering queue (delivered after
the current tool round) apart from a follow-up queue (consulted only when the run would end); Pi Durable submits
with a
requestIdandwhenBusy: followUp | steer(pi agent-core). hz takes both. Every message is first written as a request file<id>/inbox/<request id>.json(atomic, idempotent by request id; it holds the text, which the spine never does, and expires with the bodies,retention_days). A message to anawakeHeinzelmännchen during gather or think is then recorded asturn.steered {request_id, hash}and reaches the model before its next request; one that arrives during act or finish, or that the user marks "after this", is recorded asturn.requested {cause: asked, key}and becomes the next turn, whose body receives the text. Any other wake that finds the Heinzelmännchenawakeis aturn.requestedtoo (Reactor's single flight with coalescing): when the turn finishes, the writer starts one follow-up turn with all requested causes, never two turns in parallel per Heinzelmännchen. The request file exists before the model sees anything, so a crash loses no message; the writer (21) appends the spine line at its next step boundary.hz heinzel stop <name>cancels the running step, finishes the turnabandonedwith reasonstoppedand keeps queued messages as the next turn's input, as pi returns them to the editor. skippedsays why, and a slot where nothing moved costs nothing. The disposition carriesreason: nothing-moved | check-unmet | done-when-unmet | cap | throttledandmoved: [inputs];check-unmetandthrottledare reserved for the trigger kinds a later ADR records. Aschedulewake first gathers and fingerprints; when no input moved since the last turn and the contract'shz.render_on_scheduleisif-moved(ADR 0021 §2: whathz heinzel newwrites; an absent key meansalways), the tick writes one lineturn.skipped {causes, keys, reason: nothing-moved, moved: []}and never opens a body or calls a model: Reactor's zero-costskippedreceipt, so the timeline shows "checked at 15:00, nothing moved" rather than silence.turn.skippedis final for its keys under (5), like aturn.finished. Adone-when-unmetskip has spent its model call; onlynothing-movedis free. Until ADR 0054 records how a skip counts towards a promise,if-movedis refused for team contracts (hz heinzel check), so no team promise showslatefor a slot that rightly did nothing. A skipped turn is never rerun by the contract'sretries; the user may stillhz heinzel retryit.- An effect ledger, replay-safe tools, a resume limit. Every effect writes
effect.started {key, effect}before it is performed andeffect.done {key, hash}(oreffect.failed {key, error}) after; the effect's own event (draft.placed,question.asked, a notice) follows with the same key. A resume that findsstartedwithout an outcome does not perform again: for an effect in the world it asks the adapter whether the effect is there by its key (ADR 0038 §2's tag lookup), then writesdoneor performs it once; for a local effect (a notice, a question) it checks hz's own record. Pydantic AI's step persistence names the same statesstarted | completed | failed | unknown_after_crash(Pydantic AI). Every tool declaresreplay_safe(ADR 0050 §3): reads are,reportis (idempotent by turn and tool call id),handoffis not; an interrupted unsafe call is recorded as interrupted and the model gets aninterruptedresult instead of a silent rerun, Pi Durable's replay rule. A turn resumed more than[heinzel] max_resumestimes (default 3) isabandonedwith the step and error, Pi Durable'srestartLimit, so a turn that crashes at the same step every time stops spending ticks; the timeout still applies. - A turn is pinned to what it started with.
turn.startedrecordscontract_version,skills(their fingerprints, ADR 0050 §7),lessons_seq(the spineseqof the last lesson event the turn saw) andvocabulary(the version of the event kinds and step names, 18). A resumed or waiting turn runs under exactly those, so "the contract's body at the current version" of (8) means the pinned one; a contract amended while a turn waits applies from the next turn. Restate pins an invocation to the deployment that started it and DBOS recovers a workflow only under its own application version (Restate, DBOS). - New kinds; the vocabulary is frozen and migrated on read. Statements 12–16 add
turn.waiting,turn.resumed,turn.steered,turn.requested,turn.skipped,effect.started,effect.doneandeffect.failed. Further:question.asked {key, question}andquestion.answered {key}: the question is one of the short texts (3) the user is meant to read; it is asked once per key, survives a crash between asking and answer, and the answer is an input of the next turn, or of the waiting turn whoseonnames it (12); Pi Durable's memos and Pydantic AI's deferred requests are the same idea.context.edit {target, replacement}(targeta turn, lesson or body line;replacementnull or a short text) changes what the model sees and never the record, as pi'scontext_editentry;lesson.dismissedis still written and the context builder derives its edit from it, while a line the user asks hz to hide is acontext.editof its own.usage {kind: summary | lesson | handoff, tokens, tokens_cached, usd}records spend outside a turn's own model calls and counts against the caps besideturn.finished's cost, never inside it, so nothing is counted twice; pi'susageentries do the same. The event kinds and the five step names (gather, think, outcome, act, finish) are compatibility data, as Pydantic AI's durable integrations treat persisted operation names; a test pins them, and readers migrate old kinds on read and never rewrite a spine, as pi migrates session files on load. - Summaries have a schema and chain.
summary.writtenholds a fixed set of fields the user reads in the same shape every time: the asset, what the user wants, decisions, open questions, the next deadline, the sources watched. The previous summary is an input of the next, so summaries chain;[heinzel] summary_keep_turns(default 5) recent turns stay verbatim beside it, pi'skeepRecentTokenscounted in turns; and when a body is rendered into a turn's context, the cut never separates a tool call from its result (pi compaction). - The tick as a deterministic gateway. Reactor's gateway stages an arrival before the wake and keeps a cursor
per source (Reactor spec); hz's tick
does the same in a source cache,
<data dir>/heinzel/sources/: per(capability, method, args, window)the fetched result, its fingerprints (per facet, ADR 0021 §8), cursor andas_of, written before anyturn.started. A crash between fetch and wake re-wakes from the cached input and never fetches twice; gather reads the cached input when its fingerprint matches. The cache is not the index and not authoritative: losing it costs a refetch; it is private, holds content, and is pruned byheinzel:pruneunderretention_dayswith ADR 0021 §13's rules. Each input is polled when its recheck is due (inputs.<name>.recheck, default every tick; a source several inputs share is polled at the shortest), not every input every tick. Wakes in one tick are ordered by the declared graph's topological height (ADR 0052), then oldest due, undermax_wakes_per_tick, so a diamond upstream renders before its readers, and a reader wakes on an approval only when the approved text's material fingerprint moved (ADR 0052 §7). Cost is rolled up by cause:turn.finishedrecordstokens_cachedbeside tokens and USD, a turn with several causes counts under its first, andhz heinzel showsays "3.10 USD this month: 2.40 schedule, 0.70 asked". - One writer per spine. Appending needs the spine's lock (an exclusive
flockon<id>/lock, taken after the daemon's tick lock of ADR 0020 when the caller is a tick): whichever hz process ticks (hz system daemon tickfrom launchd or systemd, orhz system serve daemon) or serves a chat turn from any head takes it, and only one holds it. A web chat turn may take the lock; the web head never ticks (ADR 0020 §1, ADR 0039). Spines are not shared between machines: a chat turn is served on the machine that holds the session, and for a team Heinzelmännchen which daemon runs its wakes is still the lease of (10). Everything else reads the index and tails the spine; a page or chat that opens mid-turn gets the current row and then the new lines, never a replay, Pi Durable'swatch()contract.
Worked example: Priya's weekly report. Priya is an engineering manager at Acme; her team is Checkout. (Tests
later: tests/e2e/priya_test.py replays this timeline with a test model and test-only adapters.)
| When | Cause | What happens in the session |
|---|---|---|
| Mon | created |
In chat: "every Friday at three, draft this week's section of our weekly report from merged PRs, the board and my calendar; I approve." The turn compiles weekly-report v1 from the sentence and the org playbook's template: inputs code-host.merged_pull_requests, tracker.issues_updated, calendar.events, output section, publishes documents.place_draft into the report document, approvers [owner], schedule: "0 15 * * FRI", max_usd: 0.5. State proposed; hz approve makes it v1 and asleep. |
| Fri 15:00 | schedule, key hz-01J…@2026-10-30T15:00 |
Gather: three inputs, hashed. Think: one model turn, a last_approved call (none yet). Outcome: a draft. Act: place_draft with key hz-01J…@v1@2026-10-30T15:00@doc:checkout/weekly-report; the section lands under the tag "🧝 DRAFT (weekly-report v1): edit, then delete this line to approve". One notice. turn.finished drafted 0.31 USD; state waiting. |
| Fri 15:02 | (crash) | Priya's lid closes between draft.placed and turn.finished. At 15:40 the tick finds the open turn by its key, sees draft.placed, writes turn.finished and spends nothing. The adapter would have found the tag by its key anyway. Amended 2026-10-24: the ledger (16) has effect.done, so no adapter is asked. |
| Fri 15:02, variant | (crash, amended 2026-10-24) | The variant the ledger exists for: the lid closes after effect.started and before effect.done. The tick finds started without done, asks the adapter for a tag with the key, finds it, writes effect.done and draft.placed, then turn.finished: no second section, no second spend. |
| Mon | approval |
The tick's draft_state says the tag is gone, approved by Priya (revision history). Think: draft vs approved. Outcome: two lessons: "risks go above the summary", "name the epic, not the ticket numbers". lesson.recorded ×2, approval.observed (share changed 23 %). Priya reads them under "what I learned from you" and dismisses nothing. |
| Tue | asked |
Priya, in the sidebar chat with weekly-report selected: "also mention incidents from the on-call channel." Outcome: an amendment adding input incidents: chat.messages and chat:read to requires: it widens, so the compiler marks it as needing her approval; hz approve makes it v2. |
| Fri | schedule |
The draft records v2 and carries both lessons. The tag says "v2". |
| Wed | discovered |
The daily discovery finds a new "Checkout incidents" document the playbook's hint matches. The turn proposes an amendment to read it instead of the channel; Priya declines in chat (amendment.declined); the Heinzelmännchen won't propose that source again. |
| Fri 15:00, later | asked while awake (amended 2026-10-24) |
During think of a later Friday turn Priya writes in the sidebar "keep it under ten lines": turn.steered, and the model sees it before its next request (14). A tracker change seen in the same minute is a turn.requested and runs as one follow-up turn after this one. |
| Any time | hz heinzel list --artifact doc:checkout/weekly-report shows it among Priya's 140 Heinzelmännchen in milliseconds; hz heinzel show weekly-report shows v1, v2, seven turns, two lessons and 2.1 USD this month. |
Consequences¶
- Good: a Heinzelmännchen remembers, explains itself and improves, and the user can read every step of that in a text file hz owns; switching the model or the runner loses nothing.
- Good: crashes, sleep and double ticks are handled by construction (keys and checkpoints), which is what the thin slice's three-Friday test needs most.
- Good: hundreds are cheap: the index answers the tick and the list, a source is fetched once per tick, and spending is capped per turn, per day and per user.
- Good: lineage without content leaves the machine, as before; the never-commit list holds.
- Bad: hz now owns pruning, summarising and context building that a vendor's session would have done for us; the rules are deterministic and tested, but they are ours to get right (8).
- Bad: two stores, a spine and an index, and the reindex that keeps them honest; the index is derived to make that safe.
- Bad: ADR 0038 §4's "diff in the packet" becomes lessons the model decides, so a wrong lesson can steer later drafts;
the user sees and dismisses lessons, and a contract's
### Done whenstill gates every draft. - Bad: amends five records (below); the
workflowmodule is renamed and migrated (ADR 0051); the turn engine changes (ADR 0050). - Neutral: the maintainer's "Pi Durable" alternative is answered by borrowing its rules; a
heinzel-pirunner adapter remains possible as a handoff target (ADR 0050). - Bad (amended 2026-10-24): the vocabulary grows (waiting, steering, requests, the ledger, questions, context edits, usage), and each kind is a compatibility promise, which is why a test freezes it (18).
- Good (amended 2026-10-24): a scheduled slot where nothing moved costs one spine line and no model call (15), and wakes that pile up during a turn become one follow-up turn (14).
Confirmation¶
Not yet: proposed. When implemented:
tests/unit/kernel/session_store_test.py: append with fsync and chain, a truncated or edited line detected and contained, unknown kinds skipped, the index rebuilt from spines equal to a direct scan, 500 spines listed in under 50 ms, a process killed mid-append leaving a readable chain (tests/integration/session_store_test.py).tests/unit/modules/heinzel_turns_test.py: every cause's key, resume at each checkpoint with no second spend or secondplace_draft,abandonednever resumed, caps per turn, per day and per user,max_wakes_per_tick, one fetch per source per tick (amended 2026-10-24: per input at its recheck, from the source cache, statement 20), the context rule of (8) and summaries at the budget, bodies expired without loss of continuity.tests/unit/modules/heinzel_contract_test.py: versions,HZC050on a widening amendment the Heinzelmännchen proposes and acceptance of the same text from the user, drafts recording their version, lessons never in the contract.tests/unit/modules/heinzel_lessons_test.py: lessons from a replayed approval, dismissal, the approver and share recorded without text; no per-person aggregation anywhere (tests/unit/repository_test.py, the ADR 0024 rule).tests/integration/heinzel_test.py:hz heinzel list|show|chat|wake|new,hz drafts,hz approvethrough the CLI and MCP, the web endpoints behind the token, nothing on the unauthenticated REST head.tests/e2e/priya_test.py: the worked example above, with a test model and test-only adapters, including the crash betweendraft.placedandturn.finished.- A test that no event kind in the spine carries a field named
text,draft,transcriptor an email address (tests/unit/modules/heinzel_spine_test.py), the same linthz/statewill enforce. - Amended 2026-10-24, for statements 12–21: a frozen-vocabulary test of event kinds and step names, with old kinds
migrated on read and the spine file unchanged (
tests/unit/modules/heinzel_spine_test.py); awaitingturn survives a daemon restart and resumes on the right wake withwoken_by, anduntilwins over anonthat never matches; steering during think appears in the model's next request and in the spine, a message during act becomes the next turn, and wakes whileawakecoalesce into one follow-up turn with all causes;retryleaves two bodies and one superseded draft; the ledger resolvesstartedwithoutdoneby asking the adapter, with zero second writes in the three-Friday test; an unsafe tool interrupted by a crash yields aninterruptedresult and no rerun;max_resumesends inabandoned; a resumed turn uses its pinned contract version, skills and lessons; a schedule slot with nothing moved writes oneturn.skippedand calls no model; staged inputs are not fetched again after a crash; wakes are ordered by height; a second process can't append while the lock is held and its message arrives through the inbox once; a question is asked once per key and its answer resumes the waiting turn;context.edithides a line from the model and leaves the body unchanged; the summary has the fixed fields, chains the previous one and keepssummary_keep_turnsturns verbatim (tests/unit/modules/heinzel_turns_test.py,tests/e2e/priya_test.py).
More Information¶
- Decided by the maintainer, 2026-10-24: durable agents with one session each, one contract in versions, amendments, lessons shown to the user, hundreds per user, no central server, "hz owns the session". Technical shape by the architect.
- Amends ADR 0010 ("one bounded agent session per render" becomes one
bounded turn of a continuous session); ADR 0020 §4 and §8 (the tick wakes Heinzelmännchen
by key; a compiled job's receipt is a turn's receipt-grade event; team state on the
hz/statebranch with leases per Heinzelmännchen, as the "core and skills" note recommended); ADR 0021 §6 and §13 (a contract belongs to one Heinzelmännchen and has versions; the.record.jsonbecomes the turn body with the same retention); ADR 0029 (the runner no longer owns the session; ADR 0050 says what runs a turn); ADR 0038 §4 and §8 (lessons;hz draftsandhz approveare the view); ADR 0045 (the sidebar chat with a Heinzelmännchen selected is its session; the general chat stays). - Reconciled with the prior notes (scratchpad, 2026-10-24). Core and skills: its rename to
hz skilland theSKILL.mddirectory as the one file format are not adopted; the maintainer chose "Heinzelmännchen" and kept "contract" and its.prose.mdtext, and advice skills feed a turn as ADR 0050 says. Its state layout (orphan branch, leases, owner-then-delegate, the never-commit list) is adopted as (10). Research note: idea 2 (durable runs, idempotent writes) is (5); idea 1 (the inbox as a projection) is (11); early cutoff on the approved output's facets waits for output facets (ADR 0021 §8, later; amended 2026-10-24: the cutoff on the whole approved output is decided in ADR 0052 §7, per facet still later); the Pi Durable verdict ("borrow the concepts, don't adopt the package") stands. - Amended 2026-10-24 (statements 12–21, the Considered Options' Pi Durable attribution and the OpenProse Reactor
option): sources as read that day: earendil-works/pi (the agent loop, the
session format, compaction, Pi Durable's README and spec), Pi Durable,
Temporal, Restate,
DBOS,
Inngest,
Cloudflare Agents,
LangGraph,
Pydantic AI durable execution,
OpenProse Reactor and OpenProse.
Rejected from them: replaying code (Temporal, DBOS, Inngest), since a turn is five named steps whose only
non-deterministic step is rerun from the last recorded message; Continue-As-New, since bodies expire and the spine
stays small; undo by popping the last exchange, since the spine is append-only (
retryandcontext.editare the user-facing equivalents); a memory the model edits through a tool, since that would be a permission it grants itself. Amends ADR 0028 (fingerprints per facet) and ADR 0052 §7 (downstream wakes only when the approved output moved); ADR 0021 §2 and §8 gain the keys and facets these use. - Open: whether a retired Heinzelmännchen's spine should ever be deleted (recommendation: only by the user, with
hz heinzel forget, and its receipt-grade events stay inhz/state); whether lessons may be shared with a team's Heinzelmännchen of the same contract (recommendation: no; they are about one user's edits); the exact id prefix. Added 2026-10-24: how a session follows its user across their own machines, given one writer per spine and spines that aren't shared (statement 21; recommendation: the machine running the daemon holds the session, others submit through the web head paired to it, ADR 0046); when ADR 0054 counts anothing-movedskip as a kept promise, so team contracts may useif-moved(statement 15).
Staged plan. PR-sized steps, each with its acceptance criterion; (1) and (2) can run in parallel with ADR 0051's rename, which must land before (3).
| # | Step | Acceptance |
|---|---|---|
| 1 | Kernel SessionStore primitive: append-only JSONL with chain and fsync, turn bodies, the derived SQLite index with seq watermark and reindex |
The unit and integration tests of the first Confirmation bullet pass; nothing in the kernel names a Heinzelmännchen |
| 2 | The heinzel module (ADR 0051): ids, names, states, the contract in versions, hz heinzel list \| show \| new, hz heinzel migrate for today's contracts and records |
Every existing .prose.md becomes a Heinzelmännchen at v1 asleep; list filters by artifact, state and contract; migrate twice changes nothing |
| 3 | Turns with Pydantic AI (ADR 0050) for causes schedule, asked and created; drafts as local files; hz heinzel chat \| wake |
The weekly-digest fixture drafts on the daemon and from chat into the same session; a killed process resumes without a second spend |
| 4 | hz drafts, hz approve on local drafts, then in place through the first write adapter (ADR 0038); drafts record their contract version; the approval turn and lessons |
Approving yields approval.observed and lessons; a dismissed lesson leaves the prompt; the tag carries id and version |
| 5 | Amendments: proposed by the Heinzelmännchen or the user, HZC050, versions, hz approve of amendments and of proposed Heinzelmännchen |
Priya's v2 flows end to end; a widening amendment from the model is refused and the same text from the user accepted |
| 6 | Causes source-changed and discovered: fingerprints per source, one fetch per source per tick (amended 2026-10-24: per facet, staged before the wake, each input at its recheck, wakes ordered by height, statement 20), max_wakes_per_tick, the daily caps |
200 asleep Heinzelmännchen cost one index query per tick; an input that moved wakes exactly its watchers |
| 7 | Handoff to a coding agent (ADR 0050) and what of its transcript is kept | A turn hands a task to the bound runner and continues with its report; the spine holds hashes and cost, the body the transcript |
| 8 | Team state: receipt-grade events to hz/state, leases per Heinzelmännchen, owner-then-delegate |
Two daemons against one Acme team repository run a team Heinzelmännchen once per slot; main stays protected; the lint refuses text |
| 9 | Web: "My Heinzelmännchen", the page per Heinzelmännchen, the sidebar chat as its session | The page shows versions, timeline, lessons and the pending item; the sidebar message appears in hz heinzel show |
| 10 | tests/e2e/priya_test.py |
The worked example passes with a test model and test-only adapters |
| 11 | The action log (ADR 0052): hz-written entries from turn steps, the report tool and hz heinzel report, the lint; hz graph from logs and contracts with filters and --json |
The Priya fixture's graph shows weekly-report reading the tracker and writing the report document, declared and observed apart; golden JSON |
| 12 | The graph Heinzelmännchen (aliases) and the team graph from hz/state metadata |
Two users' daemons yield one team graph on each machine; another user's unreadable artifact shows as a name |
| 13 | The flow (ADR 0052, once confirmed): needed_by upstream, repeated nudges, fallback with fell_back, declared escalation |
Theo's my-row unapproved at Friday noon: Priya's report proceeds stale and says so; nobody is told but the two users |