0050: Pydantic AI runs a turn; a coding agent is a handoff¶
Context and Problem Statement¶
ADR 0049 makes hz the owner of a
Heinzelmännchen's session: hz builds every turn's context from the spine and must get back typed results (a draft with
its contract version, lessons, an amendment, a question). Today a render is one Claude Code headless run
(ADR 0029): the prompt goes in, text comes out, Claude Code keeps the
transcript, and the only typed boundary is a result event parsed defensively. The chat
(ADR 0045) resends its transcript each message because
no runner's session is ours, and reaches hz's operations only through a stdio MCP server started by the agent. The
maintainer leans towards Pydantic AI running each turn with typed tools backed by our services and structured outputs,
model-agnostic, and named Pi Durable as an alternative. ADR 0029 says "no agent SDK in core". What runs a turn, and
what becomes of Claude Code, Codex and the other coding agents?
Decision Drivers¶
- The session is hz's (ADR 0049): the engine must take a context hz built and give back typed messages hz can append, prune and replay; a runner that keeps its own transcript can't.
- Typed boundaries (tenet 10): tools are our services with pydantic signatures; the outcome is a pydantic model; a draft that doesn't validate is a failed turn, not a surprise.
- Tenet 8, model-provider agnostic: one engine, every provider and any OpenAI- or Anthropic-compatible company gateway, by configuration; no vendor's loop owns what a Heinzelmännchen does.
- Permissions live in code: a turn may request a write, only hz grants it (ADR 0028,
the
ActionGateof ADR 0045); the engine must not bring its own tool permissions. - Testable without a model: the suite runs no real agent today (ADR 0021 Confirmation) and must keep that.
- Honesty about what a coding agent gives that a library doesn't: the user's subscription login, a shell, files, the user's own skills and plugins, and a mature harness for heavy work.
Considered Options¶
- Pydantic AI runs every turn in process with typed tools and structured outputs; coding agents stay
agent-runneradapters, reached by ahandofftool for heavy work (chosen) - Claude Code headless as the turn engine (the status quo of ADR 0029, extended with
--resume): it keeps the transcript under~/.claudein its own format, so hz would mirror what it can parse fromstream-jsonand lose the rest; its sessions are per machine and per vendor; its tools, permissions and context management are Claude Code's, tuned for coding, not for a Heinzelmännchen that reads a tracker and places a section; typed tools reach it only through an MCP server hz must start for every turn (ADR 0045), and structured outputs only as text to parse. Its real advantages (the user's subscription login, a shell and files, the user's skills and plugins) are exactly what heavy work needs, so it is kept as the handoff. - Pi Durable (TypeScript, three weeks old; attribution amended 2026-10-24: this option first read "Earendil,
TypeScript", as if apart from pi, but it is the pi project's own durable harness,
@earendil-works/pi-durablein earendil-works/pi, beside the pi coding agent): a harness that owns conversations, documents and tasks in SQLite with checkpoints and idempotent submissions. It is a runtime, hz would run inside it, in another language, against an API that "changes without notice". Its rules are borrowed in ADR 0049; aheinzel-piadapter can make the pi agent a handoff target through the command runner (statement 13). - Pydantic AI durable execution (added 2026-10-24:
TemporalDurability,DBOSDurability,PrefectDurabilityinpydantic_ai.durable_exec, and the Restate and other integrations its documentation lists): each journals model requests and the tool calls it wraps in an engine's store. Rejected: each needs a server or a system database (DBOS runs on SQLite but recommends Postgres, and pickles dependencies and tool outputs); with DBOS, plain function tools are not wrapped unless we add its decorator ourselves, so the checkpoint granularity would still be ours to enforce; the journal would be a second store beside the spine in a format we don't own, against tenet 8, and for hosted engines against the spirit of ADR 0027; and waiting, timers and event wakes would be the engine's, while the daemon already has them (ADR 0049 statements 12 and 20). The spine is the durable layer; Pydantic AI is the loop. - Pydantic AI Harness
StepPersistenceas the turn body (added 2026-10-24, docs: append-only step events, a continuable snapshot at settled tool boundaries, a tool-effect ledger): rejected as a dependency, because the Harness is 0.x and changes between minors, its snapshots hold messages only, and it states that automatic execution recovery is not implemented. Its ledger states (started,completed,failed,unknown_after_crash) are borrowed as ADR 0049's effect ledger (statement 16). - OpenProse Reactor's render step (added 2026-10-24, openprose/reactor): it renders through a vendor agent SDK with a provider key, against ADR 0035; its reconciler's rules are borrowed for the wake loop in ADR 0049, and the Pydantic AI engine here is the equivalent seam.
- A hand-written loop over a provider SDK (
anthropicoropenai): it is what Pydantic AI already is, with provider-specific tool-call shapes, retries and streaming to maintain ourselves, and it ties the core to one provider unless we write the abstraction Pydantic AI has. - The Claude Agent SDK: a strong harness, one provider, Claude Code's tool and permission model in process; against tenet 8 and the same ownership problem as headless.
- Another Python agent framework (LangGraph, the OpenAI Agents SDK, smolagents): LangGraph brings a graph runtime and a large dependency tree for a loop whose shape ADR 0049 fixes anyway; the OpenAI Agents SDK is one provider's (it reaches others only through compatible endpoints); smolagents is built around code-executing agents. Pydantic AI is the one built on the models we already use everywhere (pydantic), provider-agnostic by design and small.
Decision Outcome¶
Chosen option (proposed): Pydantic AI is the turn engine, in the core, with typed tools that are hz's services and a
structured outcome hz acts on; coding agents remain agent-runner adapters that a turn reaches through one handoff
tool for heavy, file-bound work, and what comes back is their report, not their session. The maintainer's lean is
confirmed; the costs are named under Consequences. Checkable statements:
- The dependency.
pydantic-ai-slimis a dependency of the core; provider packages are extras (heinzel[anthropic],heinzel[openai],heinzel[google], ...), installed with the core the way plugins are (ADR 0006:uv tool install --with), andhz system doctornames the missing extra for the configured model. The import-linter contract "the core imports no vendor client or agent SDK" keeps forbiddinganthropic,openaiandclaude_agent_sdkinheinzel's own code and allowspydantic_ai: our code talks to one model-agnostic library, the library talks to the vendors. This reverses ADR 0029's "no agent SDK in core" for the turn engine alone, because a session hz owns needs an engine hz calls, and one provider-agnostic library is the smallest way to stay provider-agnostic. Facts about Pydantic AI below (typed tools withRunContextdependencies,output_typestructured outputs,message_historycontinuation with JSON (de)serialisation of messages,UsageLimits,Agent.iter()for stepping,TestModelandFunctionModel) are from its documentation as read on 2026-10-24 and are verified against the pinned version when step 3 of ADR 0049's plan is built; a feature that isn't there is replaced by our own code, never by a prompt. Amended 2026-10-24: re-verified againstpydantic-ai-slim2.61.0; what changes is statement 9. - One
Agentper turn, built by hz.heinzel.modules.heinzel._enginebuilds a Pydantic AIAgentfor each turn with: the instructions and messages ADR 0049 §8 fixes (the message history is hz's spine rendered into Pydantic AI's message models, which serialise to JSON and so into the turn body), dependencies typed asTurnDeps(the Heinzelmännchen's capability handles, its session, the cause and inputs), the tools of (3) andoutput_type = Outcome(4). Steps are driven withAgent.iter(), so hz appends a checkpoint after every model request and every tool call (ADR 0049 §5) and stops the run between steps when a cap is reached; the engine never runs a turn hz can't checkpoint. Amended 2026-10-24: the message record is aConversation, the checkpoint may be a capability hook, and steering uses the run's queue (statement 10). - Tools are hz's services, read-only, typed. A turn's tools are generated from its contract: one tool per declared
input (
read_<name>(): the input as fetched for this wake, from the body, never fetched again),search_<capability>(...)for the window-taking methods of the capabilities the contractrequires(through the Heinzelmännchen's handle, requester kindheinzel, moderead, ADR 0028),last_approved(),lessons(),previous_turns(n),handoff(task, files)(6) andreport(verb, target, description), which appends to the Heinzelmännchen's own action log and nothing else (ADR 0052). Every tool's signature is a pydantic model, so a bad argument is a validation retry inside Pydantic AI, not an exception out of the turn. No tool writes to the world: there is noplace_drafttool, nosend, nopost; the model asks for an effect in its outcome and hz performs it in code after the turn, under the contract'spublishes, ADR 0038's tag and ADR 0049's idempotency key. Strings tool results carry passclean_textandredact_emailsas every packet does. In the chat with a Heinzelmännchen (causeasked) the same tools apply, plus hz's read operations and theActionGateof ADR 0045 as in-process tools: a read runs, a write becomes a proposal the user confirms; the gate's policy (what never runs from a chat) is unchanged. Amended 2026-10-24: every tool declaresreplay_safe(statement 11), and the run pauses on a proposal through the library's deferred tool requests (12). - The outcome is structured.
Outcomeis one pydantic model the turn must return:report(one line for the spine),draft: Draft | None(text, the output name, the contract version it was made with, the### Done whenitems with the model's check of each),lessons: list[Lesson](sentence, hunks),amendment: Amendment | None(the proposed contract text and why),question: str | None,summary: str | None(ADR 0049 §8) andnothing_to_do: boolwith its reason. An outcome that fails validation after Pydantic AI's retries is afailedturn with the validation error in the report. A draft whose### Done whencheck lists an unmet item is not placed; the turn endsskippedwith the item, so a postcondition gates the write (OpenProse's rule, borrowed). - Model and budget are configuration.
[heinzel] model(a Pydantic AI model string,anthropic:...,openai:..., or a compatible gateway with[heinzel] base_url) and[heinzel] model_fastforapprovaland summary turns; a contract may hint a class (agent.model_class: fast | standard | best, mapped in configuration), never a vendor (ADR 0029). The API key is aCredentialLet(heinzel/model-api-key, ADR 0030) or the gateway's own login; a projecthz.tomlcan set none of these (ignored_in_project). Budget: Pydantic AI reports token usage per request; hz prices it with[heinzel.prices](defaults shipped for the models hz names, overridable; a model without a price runs only when the user set one) and stops the run between steps once the turn's cap would be crossed, with aUsageLimitstoken ceiling derived from the cap as the hard stop inside a step. A cost hz can't compute counts at the cap, as the chat does today. - A coding agent is a handoff.
handoff(task: str, files: dict[str, str], budget_share: float)runs the boundagent-runner(ADR 0029: Claude Code, the command adapter, a Codex or pi adapter from a plugin) once, read-only (moderead), in a private working directory holdingfiles(inputs the turn chose, never credentials), with the task as its prompt, the contract's body as its standing instructions, a share of the turn's remaining cap and timeout, and returns the runner's report as the tool result. It is for work that needs a shell, many files or a mature coding harness: reconciling a release-notes page against a repository, checking a document against a code base, drafting from a repository's history. The coding agent writes nothing outside its directory and places nothing; the turn decides what to do with its report. What is kept: the spine'sstep.handoffholds the runner, model, status, tool-call counts, cost, duration and the hashes of task and report; the turn body holds the task, the report and the runner's transcript as the adapter returns it (stream-jsonfor Claude Code), expiring with the body (ADR 0049 §3). The coding agent's own session files are its own and hz never depends on them. With no runner bound,handoffis absent from the tools and the instructions say so. Amended 2026-10-24: the pi agent is a named handoff target (statement 13); an interrupted handoff is never rerun (11). - Skills feed the turn. Advice for how is Markdown in the Agent Skills format
(ADR 0014):
skills/<name>/SKILL.mdshipped by a plugin and listed by its playbook (PlaybookLet.skills, additive, ADR 0037), in the team repository'sskills/, or in the user's own directory. A contract names the skills it uses (hz.uses, a key ADR 0021 lists as deferred and this record defines); a skill may also declare where it applies (hz.applies: asset types, roles, capabilities) and is then loaded for every matching turn. They are rendered into the instructions after the contract and before the lessons, general to specific along the layer stack (ADR 0040); each one's fingerprint is recorded inturn.started. A skill is advice: it can't add an input, a tool, a target or a cap (HZC051when its frontmatter tries). The same files work as plain skills in Claude Code, Codex and Cursor, which is the point of the format. - The chat engine. ADR 0045's chat sessions run on the same engine: hz's operations as in-process tools (reads run,
writes are proposals through the
ActionGate, paused through the library's deferred tool requests since the amendment of 2026-10-24, 12), the same caps, the same streaming throughProgress.emitter(). The stdio MCP server of ADR 0045 is no longer needed for the chat itself and stays for coding agents: a handoff's tool profile may name it so a coding agent can read through hz (and still can't write).hz system runner askstays for one-off questions.
Amended 2026-10-24: verified against Pydantic AI 2.61.0. The facts of (1) were checked against the released
pydantic-ai-slim 2.61.0 package and its documentation (persistence,
durable execution), after research into the
pi agent loop and durable-execution engines (ADR 0049's amendment of the same day). Statements 1–8 hold; these are
added.
- What 2.61.0 has.
pydantic_ai.conversation.Conversationholdsmessages,usage,conversation_idanddeferred_tool_requests, serialises throughConversationTypeAdapter, and runs takeconversation=besidemessage_history=.Agent(capabilities=[...])is the extension seam, with hooksbefore_run,after_run,on_run_error,before_node_run,after_node_run,before_model_request,after_model_request,before_tool_executeandafter_tool_execute.AgentRun.cancel()stops a run and keeps what completed;RunCancelled.all_messages()is a snapshot to resume from, with unanswered tool calls closed by synthesised results.AgentRun.enqueue(..., priority="asap" | "when_idle")queues content into a running run (drained between steps). A tool withrequires_approval=Trueends the run withDeferredToolRequests, whosebuild_results()gives theDeferredToolResultsfor the next run'sdeferred_tool_results=; tools carry freemetadata. - The engine uses them. The turn body's message record is a
Conversation, not a bare message list, so usage spans the whole turn (UsageLimitsacross resumes) and a pending deferred request travels with it. The per-step checkpoint of (2) is a hz capability whoseafter_node_runappends the step to the spine, preferred over drivingAgent.iter()by hand as long as theConversationround-trips unchanged; either is allowed.hz heinzel stopisAgentRun.cancel(), and the snapshot is the body's last record. A steered chat message (ADR 0049 statement 14) isAgentRun.enqueue(..., priority="asap")after itsturn.steeredline is durable; follow-ups are notwhen_idlecontent but the next turn, because a turn ends with its own outcome and key. - Tools declare replay safety. The generated tools of (3) carry
metadata={"replay_safe": ...}:Truefor every read and forreport(idempotent by turn and tool call id),Falseforhandoff. On a resume the engine reruns a safe call and, for an unsafe one interrupted by a crash, recordsstep.handoffasinterruptedand gives the model aninterruptedresult, as Pi Durable does, never a silent second run. - The chat's gate pauses through the library's approval. In the chat (8) and in a Heinzelmännchen's chat
turns, write tools are
requires_approval=True: the run ends withDeferredToolRequests, and each request becomes ADR 0045'sProposedCall, stored as today (proposals.jsonlfor the general chat, the turn body for a Heinzelmännchen). Execution does not move into the library: ADR 0045'sconfirmstill runs the stored arguments once throughKernel.invoke, under theActionGate's policy, and its result (or the decline) goes back as the tool's return indeferred_tool_resultswhen the sameConversationcontinues. For a Heinzelmännchen the spine recordsquestion.askedwith the proposal's id and hash only, never its arguments, and the chat turn waitson: {kind: answer, key}(ADR 0049 statement 12) and resumes as the same turn; the general chat has no spine and keeps its own store. The pause is a stored state, so a crash between proposal and answer loses nothing. Turns of a Heinzelmännchen outside the chat still have no write tool (3). - The pi agent as a handoff target. A
heinzel-piplugin'sagent-runneradapter runs the pi coding agent in its JSON or RPC mode (pi); its JSONL session file is provider-neutral and kept as the handoff transcript in the body, asstream-jsonis for Claude Code.
Consequences¶
- Good: typed tools and outcomes, a session hz can append and replay, every provider and gateway by configuration, and
a suite that runs with
TestModelandFunctionModelinstead of a recorded CLI. - Good: the chat loses a process boundary (no stdio server per message) and gains the same engine, caps and gate.
- Good: heavy work keeps the best tool for it, the user's coding agent with their subscription, skills and shell, and the Heinzelmännchen keeps the decision and the record.
- Bad: logins. A Pydantic AI turn needs an API key or a gateway; a Claude subscription's OAuth login works only in
Claude Code. Users without a key can still run every Heinzelmännchen whose turns are handoffs, and a plugin may offer a
Pydantic AI
Modelthat drivesclaude -pfor the model step alone (text in, text out, tools emulated); that is slower and lossy, which is why it is a plugin and not the default.hz system doctorsays which of the two a machine has. - Bad: we own what a harness gave us: context building, summaries, retries and pricing. ADR 0049 §8 and (5) make them deterministic and tested; they are still code to keep.
- Bad: a new dependency with its own release cadence in the core, against ADR 0029's letter; pinned, audited and
behind one module (
_engine) so a replacement is one module's work. - Bad: skills in the turn are a new prompt-injection surface, like contract bodies today; they are files a person installed or merged, pinned by fingerprint, and never grant anything (7).
- Neutral: the
agent-runnercapability and both core adapters stay as they are; ADR 0029's planned adapter contract tests now also cover "returns a transcript the handoff can keep".
Confirmation¶
Not yet: proposed. When implemented:
tests/unit/modules/heinzel_engine_test.py: a turn withFunctionModelproduces a validOutcome; an invalid one endsfailedwith the error; an unmet### Done whenitem endsskippedand places nothing; the cap stops a run between steps and the token ceiling inside one; a checkpoint follows every step;message_historyround-trips through the turn body.tests/unit/modules/heinzel_tools_test.py: the generated tools match the contract's inputs andrequires; every tool is read-only (a write-mode method is never exposed, checked against the capability registry); tool results passclean_textand carry no email address;handoffis absent when no runner is bound.tests/unit/modules/heinzel_handoff_test.py: a handoff runs the bound runner once with the files, share and instructions, keeps hashes and cost in the spine and the transcript in the body, and writes nothing outside its directory (with the fake runner oftests/integration/runner_test.py).tests/unit/modules/heinzel_skills_test.py: skills resolve along the layer stack, render in order, are fingerprinted, andHZC051refuses a skill that tries to grant.tests/unit/modules/runner_chat_test.py(adapted): the chat's reads run and writes are proposals under the same policy;tests/integration/chat_test.pywithout a stdio server.- Amended 2026-10-24: the
Conversationround-trips through the turn body withConversationTypeAdapter; the checkpoint capability appends one spine line per node;stopcancels and resumes from the snapshot; a steered message reaches the next model request; generated tools carryreplay_safeand an interruptedhandoffis reported, not rerun (tests/unit/modules/heinzel_engine_test.py,heinzel_tools_test.py); a chat write ends the run with a deferred request,confirmruns it throughKernel.invokeonce and its result resumes the sameConversation, the spine holds no proposal arguments, and a crash in between loses nothing (tests/unit/modules/runner_chat_test.py). - The import-linter contract in
pyproject.tomllistspydantic_aias allowed and the provider packages as forbidden;tests/unit/repository_test.pychecks the extras exist for every model prefix the defaults name. No test calls a real model.
More Information¶
- The maintainer's proposed ADR title "hz owns the session, Pydantic AI runs the turn" is split: ownership is ADR 0049, the engine is this record. The maintainer's lean towards Pydantic AI is confirmed after weighing Claude Code headless and Pi Durable above.
- Amends ADR 0029 ("no agent SDK in core" no longer holds for the
turn engine; the
AgentRunnerprotocol, its adapters and its configuration are unchanged and become the handoff seam) and ADR 0045 (the chat runs on the same engine with in-process tools; its gate, policy, sessions, caps and streaming stay; the stdio MCP server serves handoffs). - Builds on ADR 0014 (the skills format), ADR 0037 and ADR 0040 (where skills come from and in what order), ADR 0028 (handles are the tools) and ADR 0030 (the key).
- Open: whether to ship a
claude-codePydantic AIModeladapter as a first-party plugin for users without an API key (recommendation: yes, after the thin slice works with a key; it is the subscription story for many users); whetherhandoffshould be able to hand files back (a drafted document) rather than a report only (recommendation: a report plus files hz reads from the directory, both into the body, nothing placed by the agent); the defaultmodel_fastfor lessons and summaries.