ADR 0015 — mklang console: an agent-first operational TUI whose brain is a machine¶
Status: Accepted (M1–M3 shipped in package 0.7.0; see docs/guides/console.md)
Context¶
mklang now has every non-interactive surface: CLI verbs for authors, a library for embedders, an MCP server for agent hosts, a stdlib of callable architectures. What it lacks is an operational front door — the claude-code/opencode experience: type what you want in natural language, watch the work happen live, answer escalations inline, iterate.
Everything such a console needs already exists as a seam: host.prepare_* /
check_machine / describe_machine, the registry with run-by-name, engine.run
with suspension/HITL/checkpoints, scripted testing for its own verification.
Two things are missing:
- Live execution events.
engine.runis a black box until it returns; a console must render state-by-state progress while the run is in flight. - The interactive loop itself — and a decision about what powers it.
The decisive design question is the agent brain. A conventional answer is a Python tool-calling loop. But mklang's whole thesis is that an agent should be a readable, verifiable machine — a console whose brain is a hidden Python loop would be the project not believing itself.
Decision¶
Add an optional console surface: extra mklang[console] (Textual),
subcommand mklang console (guarded import, like the MCP extra), module
src/mklang/console/. Three commitments:
1. The brain is a bundled .mk machine¶
The console's agent loop is agent.mk shipped at
src/mklang/data/console/agent.mk — a ReAct-shaped machine (understand →
act via tool: states → observe/accumulate → gate: done | loop), run once
per user turn with the conversation carried in its context. It is not in
the stdlib (it depends on console-registered tools) and is user-swappable:
mklang console --agent my_brain.mk replaces the brain with any machine that
honors the same tool contract. The console's intelligence is a document you
can read, check, lint --llm, scenario-test, and fork.
The console registers the brain's host tools (via run(tools=...), no
entry points):
| tool | effect |
|---|---|
list_machines / describe_machine |
registry discovery (wraps host.describe_machine) |
read_machine |
source of a machine (bundled or workspace) |
check_machine |
host.check_machine verbatim — the authoring loop's verifier |
write_machine |
write a .mk inside the workspace only (confirm on overwrite) |
run_machine |
commission a machine (name/path/source + inputs JSON + budget); the run's events stream to the UI; suspensions are brokered to the user (below) |
ask_user |
direct clarification — blocks the machine on a UI reply |
Structured tool inputs ride as JSON strings (tool inputs are rendered strings by contract); the tools parse them. Nothing here needs a language change — the brain is an ordinary 0.3 machine.
2. Engine event seam (prerequisite, host-visible only)¶
engine.run(..., on_event=None): an optional callback receiving small dicts —
state-start, state-done (output preview, gate, policy, to, tokens),
branch-done for fan-out and additive run-finished (status / error / usage)
— with depth so
nested call:/tool-launched runs render as a tree. Purely additive; no
behavior change when absent; the same seam later serves OpenTelemetry export
(ROADMAP). The trace stays the canonical record; events are its live shadow.
3. HITL and budgets become UI moments, not failures¶
- A target-machine escalation (
escalateunder HITL) suspends insiderun_machine; the tool brokers the gate's question to the UI, blocks on the reply, injectshuman.reply, and resumes — the agent machine just sees the tool observation. Implementation: the tool runs on the engine's worker thread and awaits a UI future (thread-safe queue into Textual). - Per-turn budgets reuse checkpoints: the brain runs with a step budget and
optional
cost_budget; exhaustion suspends, the console shows spend and asks "continue?", resuming the same frames. Cost is always visible in the status bar (session total, per-turn). write_machineis confined to the workspace directory; runs of machines whosetool:states have real side effects surface a consent prompt listing the tools before the first execution (SPEC §11 applies to the console too).
UX shape (Textual)¶
One conversation pane (user turns, agent narration, live run tree), an input
line with slash commands that bypass the brain for operator use (/machines,
/run <name>, /check <path>, /resume <ck>, /budget, /provider,
/quit), and a toggleable inspector (context blackboard, full trace, usage).
Sessions persist under the host state root — blackboard, event transcript
(JSONL), checkpoints — and --continue reopens the last one. ADR 0021
later standardized the XDG location and legacy fallback;
Best practices §13
is the documentation SSOT for the current paths.
Headless testing uses Textual's Pilot plus the scripted LLM, so the console
has the same no-key CI story as everything else.
Milestones (all shipped, 0.7.0)¶
- M1 — seam + skeleton:
on_eventin the engine; Textual app with brain machine, live run tree, HITL broker, cost HUD. - M2 — authoring loop:
write_machine/check_machinerepair cycle, workspace, session persistence,--agent/--continue. - M3 — polish: slash commands, inspector pane,
/resumeof parked turns, activity tree,agent.mkscenario tests.
Follow-ups that are not console milestones: brain history windowing is documented under ADR 0017; MCP live events under ADR 0019.
Package polish (post-M3, no language change): conversation log renders agent
prose as CommonMark; untrusted text (user, LLM, previews) is never interpolated
into Rich markup tags on the log or activity tree (console/render.py). See
docs/guides/console.md § Conversation rendering. Slash /provider from the UX sketch
above was not shipped — use CLI --provider (or a later surface PR).
Consequences¶
- mklang gets its front door, and the front door is the thesis: the demo is
a machine, auditable end-to-end (
agent.mk+ trace of every turn). - The engine gains its first observability seam — needed anyway for OTel — with zero semantic change.
- The brain-as-machine bet is real dogfooding: where
agent.mkproves clumsy (authoring long documents, structured decisions), that pressure feeds the language (e.g.parse: json) instead of being hidden in Python. - Scope guard: the console is an operational surface, not an IDE — file editing beyond the workspace, git, and arbitrary shell stay out.