0057: AI access is a module: one choice like the theme, options are adapters of ai-access¶
Context and Problem Statement¶
hz reaches AI in more than one way. Today every run goes through the agent-runner capability: Claude Code headless
or a configured command (ADR 0029), and the chat runs on it
(ADR 0045). ADR 0050
adds Pydantic AI for a Heinzelmännchen's turns, configured by [heinzel] model, model_fast and base_url with an API
key. Companies add a third way: a company CLI that grants employees access to AI, either by handing out an endpoint
and a short-lived credential for an OpenAI- or Anthropic-compatible API, or by wrapping a coding agent (made-up
example: acme ai claude …). Nothing in hz says which of these exist on a machine, whether each is signed in, which
one is used for what, or what it costs; the person has no single place to choose. The maintainer asked for an ai
module that owns this, selected the way a theme is (ADR 0044): one visible
choice, a list of the installed options with their status, plugins contributing options.
Decision Drivers¶
- One choice a newcomer can make in Settings → General, next to Theme; routing per use is an expert refinement.
- Tenet 8, model-vendor and agent agnostic: an option is how hz reaches AI, never a vendor requirement.
- Companies roll out their gateway by data or a plugin, without a core release; the core ships the generic shape.
- The kernel stays small: protocols and one injection seam, no access logic.
- Status is cheap and spends nothing; a live check is explicit and says what it cost.
- No behaviour change for the chat in the first slice.
Considered Options¶
- Options are adapters of a new
ai-accesscapability; the choice is its binding; theaimodule lists, tests and selects them and composesagent-runner(chosen) - A new let kind, like
ThemeLet: a theme is inert data, an access option has behaviour (status, a live test, an endpoint), credentials and hosts, which is whatProviderLetalready models with hosts, the capability matrix, doctor and ambiguity checks. A second mechanism for the same shape would duplicate all of it. - Fold
agent-runnerintoai-access(the coding agent as a facet of the access option): it forces agent-only plugins (a Codex adapter) to become access options and bendsAgentRunner.run, which is about one bounded session, into a protocol about access. Both stay one-purpose and the access option names its runner instead. - A separate
[ai] accesskey next to the binding: the maintainer's first sketch. Two keys for one choice drift, and the binding already has what the choice needs: user scope only (a projecthz.tomlcan't set it), unknown bindings reported by doctor, and a company playbook may recommend it (ADR 0037), so a company layer suggests its gateway for free. The configuration key is[capabilities] ai_access; the words people read are "AI access".
Decision Outcome¶
Chosen option (proposed): AI access options are adapters of the ai-access capability; [capabilities] ai_access
is the one choice, made with hz ai access use; the built-in ai module lists every installed option with its
status, tests one on request, and keeps agent-runner as the runner an option names. Checkable statements:
- Terminology. AI access is how hz reaches AI; its options are Claude Code, Pydantic AI with an API key, a
company gateway. Model is the model; vendor (Anthropic, OpenAI) only where the vendor matters. "Provider" is not
used for access options, because it means a model vendor. The capability is
ai-access, the moduleai, its submoduleai.access, the commandshz ai access list|use|test, the web label "AI access". - Kernel: a protocol and one seam.
AiAccess(status() -> AccessStatus,test() -> AccessTest, both moderead) and its models are inheinzel.kernel._ai, public inheinzel.status()runs no model and spends nothing: a CLI on PATH, a login the CLI reports offline, a credential present.test()is the one live call, as small as the option can make it, and reports what it cost.Adapters[P], injected by type, is a read-only mapping from adapter name to a handle on every installed adapter ofP's capability, for the module that owns choosing among them; it is granted only to a requester that declared the capability (ai-access:read), in read modes, built lazily like any handle. Nothing else in the kernel changes. - The choice is the binding, like the theme.
[capabilities] ai_accessdefaults toclaude-code.hz ai access listshows every installed option with its title, summary, status (signed in,not signed in,not tested,tested,test failed), the hosts it reaches, where it comes from and an in use marker, then what each use runs today.hz ai access use NAMEwritescapabilities.ai_accessto the user file throughConfigEditor, ashz system theme usewritessystem.theme.name, and, when the option names an agent runner,capabilities.agent_runnertoo, so the chat follows the choice.hz ai access test [--name NAME]runs the live check and keeps its result (time, outcome, model, cost) in<state_dir>/ai/tests.json, which status reads; none is ever run implicitly. - Options, from core and plugins.
claude-codeis provided bysystem.runnerbeside its runner (one module, two capabilities, one name): signed in fromclaude auth status, tested by a one-lineclaude -pwithin a small cap; it names the runnerclaude-code.gatewayis the core's generic shape inai.access, configured by data in[ai.access]: a title, a status command (exit 0 and optional JSON{"signed_in": …, "models": […]}), a test command, the hosts it reaches, the variables it may see, a sign-in hint and the agent runner that serves it (e.g.command, with[system.runner] command = ["acme", "ai", "claude", …]). Commands are argument lists without a shell, with an environment allowlist and noHZ_CREDENTIAL_*, and come from the site, user orHZ_CONFIGfile or the environment only, never from a projecthz.toml, as the command runner's do. A company ships either a playbook that recommends the binding and these values, or a plugin with its own option (@module.provides("ai-access", name="acme")) and, if it wraps a CLI agent, its ownagent-runneradapter.pydantic-aiis inai.access: a model API through Pydantic AI, the model named in[ai.access] modelas Pydantic AI names it (anthropic:...), signed in when a key is there, tested by one request capped at a few output tokens.pydantic-ai-slimbecomes a core dependency now, as ADR 0050 decided; the vendors' clients are the extrasheinzel[anthropic]andheinzel[openai], and status names the missing one. The core imports onlypydantic_ai, never a vendor SDK, and turns its first-run banner off, because stdout may be the MCP head's protocol. - Uses and routing. Three uses: chat (ADR 0045),
turns of Heinzelmännchen (ADR 0049) and
coding handoffs (ADR 0050 §6). Every use follows
the one choice. An expert may route a use elsewhere in
[ai.roles](chat,turns,coding, each an option name; empty follows the choice), and models by class per option in[ai.models.<option>](fast,standard,best), resolved use → option → model. This amends ADR 0050 §5:[heinzel] model,model_fastandbase_urlretire before they are built; an option's endpoint (base URL, API flavour, credential) comes from the option, never from a global, and a contract'sagent.model_classmaps through[ai.models]. Routing is built with the turn engine; until then the listing says what each use runs today (chat and coding: the bound agent runner; turns: not built). - Agent runners stay, composed. This amends ADR 0029:
agent-runneris unchanged and remains the seam for bounded agent sessions and handoffs; an access option names the runner that serves its chat and coding uses (AccessStatus.runner),usebinds it, and the listing warns when the bound runner isn't the chosen option's. - Credentials. A model API key is the credential
ai.access/api-key(ADR 0030;hz system credential set ai.access api-key), or the vendor's own variable (ANTHROPIC_API_KEY); a failed test's message never repeats it. A gateway's short-lived token is never stored in the keychain, configuration, receipts or messages: the option mints it from its command when a run needs it and holds it in memory until it expires. - Cost. Budgets stay per run (
Budget.max_usd, the chat's and turns' caps). A runner reports its cost (AgentResult); a model API run is priced with[ai.prices](per model id, shipped defaults overridable; a model without a price counts at the cap, as ADR 0050 says). A test reports its cost like any run. Usage is recorded per run: every agent run (chat,ask, scheduled and manual renders, handoffs) and every live test appends aUsageEntry(time, cause, access option, runner, model, Heinzelmännchen, tokens, cost; never prompt or answer text) throughUsageLog, a kernel base class theaimodule provides with a ledger in<state_dir>/ai/usage.jsonl(private, pruned to 60 days).hz ai usage [--period today|week] [--by access|model|heinzel|cause]reports it, with today's level against[ai] daily_budget_usd(green below 75%, amber to 100%, red beyond) and a compact label (182k tok) for the footer's status item. The budget shows, it doesn't stop: each run keeps its own cap. Recording never fails a run, and nothing records withaioff. - Heads.
listis on the CLI, MCP and web heads, never the unauthenticated REST head, since it names hosts and sign-in state (ADR 0039).useandtestchange configuration or spend money and are the person's own acts: CLI and web only, so an agent on MCP can't move work off a company gateway. Bindings are read at boot: afteruse, a runninghz uior MCP server says to restart. The web app shows "AI access" in Settings → General next to Theme: the options with status and in use, a test button and a picker; routing under expert mode (ADR 0041).aiis a top-level module by the maintainer's decision: it is a user-facing choice likechat, not administration (ADR 0018). - Doctor. The
aihealth check: the chosen option is installed and signed in, as far as status can tell offline; the bound agent runner is the one it names.system.runnerkeeps its runner checks. - Model, effort and honest cost per answer.
AgentRunner.runtakes an optionalmodelandeffort(low,medium,high,xhigh,max) that override the configured ones for one run; a runner that can't pass one says so in its warnings.hz ai modelslists the models and efforts of the AI access in use (AccessStatusgainseffortsandbilling) for the chat composer's picker; a chat keeps its choice for its later messages. Every answer keeps when it was made, how long it took, the AI access, model, effort, tokens and cost, shown behind an (i).billingisplanwhen the agent ran on a subscription (Claude Code'sapiKeySourceisnone, itsauthMethodclaude.ai): its cost is what the run would cost at API prices, which the plan covers, and is labelled "uses your plan · ≈ $X at API prices" wherever it shows;apiis real spend. A run without file tools (a chat, a question, a test) is lean: its contract replaces Claude Code's own system prompt, and a chat's agent gets the everyday operations as tools ([system.runner.chat] tools = "all"for every one), since every tool's schema goes with every message.
Consequences¶
- Good: one place, one word and one choice for how hz reaches AI, in the CLI, MCP and soon the web app; a company rolls out its gateway with a playbook or a small plugin.
- Good: the capability matrix, hosts, doctor and ambiguity checks apply to access options for free.
- Good: the chat follows the choice now, without a change to
system.runner's chat. - Bad: a new kernel seam (
Adapters[P]) that hands a module handles on adapters nobody bound; it is read-only and granted by declaration, and like every handle no isolation boundary (ADR 0028). - Bad: two capabilities to keep aligned (
ai_accessandagent_runner);usewrites both and the listing and doctor say when they differ, for instance after a playbook recommended only the access. - Bad: routing and models by class are designed, not built, until the turn engine exists.
Confirmation¶
tests/unit/kernel/adapters_test.py:Adapters[P]holds every installed adapter, read modes only, built lazily, refused without a declaration.tests/unit/modules/ai_access_test.py: listing, status labels,usewriting both bindings, the kept test result, the gateway's command handling (no shell, environment allowlist, bounded output and time), thepydantic-aioption's status and a test turn throughTestModelandFunctionModel, the key kept out of a failure.tests/integration/ai_access_test.py:hz ai access list|use|testthrough the CLI head with fakeclaudeand gateway CLIs on PATH; the operations are absent from the REST head.tests/unit/public_api_test.py:AiAccess,AccessStatus,AccessTest,Adapters,UsageEntryandUsageLogare public.tests/unit/modules/ai_usage_test.py: entries carry no text, the ledger is private and never raises, today and the week split, every breakdown, the meter's tones;tests/integration/ai_access_test.py: a test and anaskare recorded and reported, andusageis absent from the REST head.
More Information¶
- Builds on ADR 0028 (adapters, bindings, hosts) and ADR 0044 (the choice and its listing).
- The
codexoption and runner, a ChatGPT plan's coding agent. Unlike ADR 0029's "other agents as plugins", the core ships Codex beside Claude Code, as both anagent-runneradapter and an AI access option that names it: the maintainer wants a ChatGPT plan as first-class as a Claude one. A run iscodex exec --json --sandbox read-only --ephemeral --skip-git-repo-check -C <dir> [-m <model>] [-c model_reasoning_effort=<effort>] [-c mcp_servers.<name>.command|args|env=...] -with the contract and the prompt on standard input (Codex has no system prompt flag); the answer is the lastitem.completedof typeagent_message, the usageturn.completed's (input_tokens,cached_input_tokens,output_tokens), failuresturn.failedanderror;codex login statussays whether it is signed in. Codex reports no cost and takes no budget or tool allowlist, and its read-only sandbox prevents writes, not reads: every run says so in its warnings. Its environment is PATH, HOME, LANG, LC_*, TMPDIR andCODEX_HOME, neverHZ_CREDENTIAL_*;[system.runner] codex_executable,codex_modelandcodex_effortconfigure it. These flags and events are verified against OpenAI's Codex documentation only (2026-10-25), not against a live run; the tests use a fakecodexon PATH. - The
chatgptoption (a ChatGPT Plus or Pro plan instead of an API key). OpenAI's "Sign in with ChatGPT" offers plan usage to open-source apps ("ChatGPT plan usage is available to all open-source partners and selected private clients", developers.openai.com/siwc/quickstart, read 2026-10-25); each person approves hz by name and can limit it in ChatGPT's settings, and a paid or remotely hosted app would need OpenAI's approval first. hz is open source and runs on the person's machine, sohz ai access login(CLI only: it opens the browser and waits for the callback on 127.0.0.1) signs in through Pydantic AI'sopenai-chatgptflow under the app name "Heinzelmännchen" and a stable installation id (<state_dir>/ai/host-id). The credentials, a refresh token among them, are one keychain credential (ai.access/chatgpt-login) that Pydantic AI reads and refreshes through it;[ai.access] chatgpt_modelnames the model. hz does not use Pydantic AI'sopenai-codexprovider, which signs in as the Codex CLI with that CLI's stored credentials; a ChatGPT plan's coding agent is the Codex CLI itself, its own access option and agent runner (next slice). - First slice: the protocol,
Adapters[P], theclaude-code,gatewayandpydantic-aioptions with thepydantic-ai-slimdependency,list,use,test, doctor. Deferred: the turn engine itself (ADR 0050),[ai.roles],[ai.models],[ai.prices], gateway endpoints and tokens, and the web Settings section. Second slice: usage recording andhz ai usage; the footer meter follows once status items carry meters. - Model APIs through Pydantic AI (the
pydantic-aioption): Anthropic's and OpenAI's own APIs with a key (model = "anthropic:..."or"openai:..."), and any OpenAI-compatible endpoint (base_url: Ollama, LM Studio, vLLM, OpenRouter) with an optional key and the model name the endpoint knows. The key is the keychain credentialai.access/api-keyor the vendor's variable, and goes only over https or over http to this machine; listings follow no redirect. The models an API offers are listed during the explicit live test (GET <base>/models) and kept in<state_dir>/ai/models.json, sostatusand the picker stay offline. Endpoints use OpenAI's client (heinzel[openai]). - Open: whether
ai.accessoptions should also offer the endpoint (endpoint()) as a third protocol method or a separate capability once Pydantic AI consumes it; the recommendation is a method returningNonefor options without one.