pydantic-ai-harness / pydantic-clai2
AGENTS.md954 starsChanged 4 months agoMITView on GitHub
Read this before touching pydantic-clai2/. The repository-level AGENTS.md still applies (no em-dashes, no Any, pyright strict, keyword-only arguments, 100% branch coverage). This file adds what is specific to the terminal shell. A thin terminal around any Pydantic AI agent. It reads a prompt, runs the agent, streams the answer, and repeats. Everything beyond that is a plugin, including the default coding tools. The three layers, and who owns what: If a change needs the agent loop to behave differently, propose…
Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.
Of the AGENTS.md files we read, the 31,439 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.
In this repository7 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- adopt-prSkill
- branch-contextSkill
- pushing-commits-to-the-repoSkill
- migrating-deep-agents-to-pydantic-ai-harnessSkill
- pydantic-ai-harnessSkill
The file
Show its rules and text
We split this AGENTS.md into the rules it gives an agent
18 rules. About 0 are common, 7 are distinctive, and 11 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈7
Distinctive · estimate
This author's own choices. Worth a look.
11
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 3
Read this before touching pydantic-clai2/.
- Distinctive: line 21
If a change needs the agent loop to behave differently, propose it in core.
- Very specific: line 99
A load failure leaves the session as it was: Import or activate errors are reported and the plugin stays unloaded.
- Very specific: line 116
Add the name to the Literal, the event dataclass, and the @overload.
- Very specific: line 117
Add the parity test entry: the Literal must equal Hooks.on's attribute names plus the host names.
- Very specific: line 120
Document it in PLUGINS.md in the table it belongs to.
- Very specific: line 132
Every key mutates immediately and replace_items redraws.
- Very specific: line 172
Do not define new palettes.
- Distinctive: line 231
Keep files concise - we don't need any 10,000 line files.
- Very specific: line 235
When adding or modifying a CLAI2 CLI UX feature, test every affected UX feature with your changes in a fresh tmux window before opening a …
- Very specific: line 241
Test a hook by building a PluginHost, registering a handler, and firing the event from the shell path that owns it.
- Distinctive: line 249
Before adding, removing, or renaming a setting, changing its type, meaning, or default, or changing the SQL schema, consider upgrades, …
- Very specific: line 253
Add regression cases to tests/test_settings_compatibility.py and affected CLI tests using the previous stored format.
- Distinctive: line 256
Verify older databases load with documented defaults for missing settings and preserve existing preferences, plugin declarations, and model …
- Distinctive: line 261
Keep validation strict for known values, new writes, and plugin declarations.
- Distinctive: line 265
Verify rejected values and unsupported schema versions leave stored data intact.
- Distinctive: line 266
If a change introduces a migration, test failure rollback as well as success.
- Very specific: line 270
Run from the repository root.
≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
CLAI 2 guide for AI code assistants
Read this before touching pydantic-clai2/. The repository-level AGENTS.md
still applies (no em-dashes, no Any, pyright strict, keyword-only arguments,
100% branch coverage). This file adds what is specific to the terminal shell.
What CLAI is
A thin terminal around any Pydantic AI agent. It reads a prompt, runs the agent, streams the answer, and repeats. Everything beyond that is a plugin, including the default coding tools.
The three layers, and who owns what:
| Layer | Owns | Never does |
| Pydantic AI core | the agent loop, hooks, events, toolsets | know CLAI exists |
pydantic_ai_harness | reusable capabilities (Coder, Shell, ...) | print to a terminal |
pydantic_clai2 | the prompt loop, rendering, /commands, plugin loading | reimplement a core hook |
If a change needs the agent loop to behave differently, propose it in core. If it is a reusable behavior with no terminal in it, it belongs in harness. Only the shell itself lives here.
The plugin model
A plugin is a module with activate(host: PluginHost), or a bare capability
class. @host.on(name) registers a handler for a lifecycle moment;
@host.on(EventClass) for a typed event; host.commands.register for a
/command; host.add for tools and instructions; host.render for custom
output; host.settings(Model) for validated config. PLUGINS.md is the user
contract. If code and PLUGINS.md disagree, fix one so they agree in the same PR.
Rules for the plugin API
- No global state. Registration goes on a
PluginHostinstance someone constructed. No module-level dicts, no import-time side effects. Tests build a host and callactivatedirectly. - Strings are keys, never payloads. Hook names are a
Literal; each name has an@overloadbinding one typed event dataclass. A handler never receives*args,**kwargs,dict, orcontext: object = None. - No string sub-dispatch. A handler does not receive
event_type: strand switch on it. Use the event class as the key. - One async spelling. No
_syncor_asyncpairs. - Observers return
None. Deciders mutate the event (event.text = ...,event.cancel()). Nothing collects a list of return values. - Fail closed, one way. A raising handler on a decidable moment cancels the action and reports the error. No per-registration "fail open" flag.
- Host hooks are
<subject>_<moment>(turn_start). Core hooks keep core's names verbatim (before_tool_execute). Do not rename a core hook for taste. - Payloads are kw-only dataclasses. Not
BaseModel. Pydantic is for the settings JSON boundary only. - No
getattr/hasattron a plugin to discover what it supports. It registered the thing or it did not. - Only four host hooks.
session_start,session_end,turn_start,turn_end. Adding a fifth needs a use case that core cannot serve; say which core hook you checked and why it does not fit.
Loading and unloading
Plugins load and unload while CLAI runs. The rules that make that safe:
- One
PluginHostper plugin. The host is the ownership scope. Everything a plugin registers is recorded on its own host, so unloading is "discard this host". Nocallback -> ownermap, no scanning registries for a plugin's name. - Load and unload only between turns.
/commandsalready run between turns, so this falls out for free; do not add a mid-run path. - Load fires
session_startfor that plugin; unload firessession_end. A plugin cannot tell whether it was loaded at startup or later, and must not need to. reloadis unload, re-import, load. Drop-in entry modules use fresh source. Installed modules useimportlib.reload, which retains globals absent from the new source. Plugins must explicitly initialize their state on activation.- Instruction order is capability order. Placement is a core
CapabilityOrdering(position,wraps,wrapped_by), not a CLAI list. - Registration is idempotent per name. A capability is bound per run
(
agent.run(capabilities=...)), so "active for the next prompt" is the natural unit; nothing rebuilds the agent. - Shipped plugins register first, in declared order. The menu's alphabetical
order is for scanning only. Registration order is the order instructions,
renderers, and status segments are consulted in, so
coder's guidance leads the prompt.customization_guide()orders itself after the guidance plugins contribute and before harnessRepoContext, so the CLAI hint never leads. - Built-ins are declarations, not code paths.
DEFAULT_PLUGINSin_app.pylists what CLAI ships enabled (coder,ask_user,repo_context,compaction,persistence,logfire). The loader treats them like drop-ins with the lowest precedence: a store declaration with the same id replaces one,disablepersists an override,removeresets it. Do not special-caseCoderanywhere else; the agent fromcreate_agent()has no coding tools of its own.coderis declared withrepo_context: falsebecauserepo_contextbinds harnessRepoContextitself; keep it that way orAGENTS.mdreaches the model twice. - Project declarations rank just above built-ins and start off.
.clai/settings.json(project_settings.py) may declare plugins; the loader takes them asproject=, every oneenabled=False, because a repository must not run code as the user on launch./plugins enableis the approval and persists the approved declaration in the store. Precedence is store, drop-in folder, project, built-in. CLAI never writes the project file. - A load failure leaves the session as it was. Import or
activateerrors are reported and the plugin stays unloaded; partial registrations from a failedactivateare discarded with the host. Registeredsession_endhandlers run withreason='error'under a shield with a five-second cooperative timeout per handler before a failed/cancelled load drops the host. Cleanup must tolerate incompletesession_start; a handler failure must not skip later cleanup.
Compaction registers harness FallbackCompaction directly with max_fraction
and context_window for both strategies. Harness owns the trigger; do not add
threshold math or an orchestrator in CLAI. Register the usage gauge after the
chain so yellow means the compacted request still exceeds the threshold.
/compact drives the same chain regardless of threshold. Only ModelAPIError,
FallbackExceptionGroup, and UsageLimitExceeded select truncation after a
summary failure; other exceptions propagate.
Adding or changing a hook
- Add the name to the
Literal, the event dataclass, and the@overload. - Add the parity test entry: the
Literalmust equalHooks.on's attribute names plus the host names. Drift fails CI, not code review. - Fire it from exactly one place in the shell.
- Document it in
PLUGINS.mdin the table it belongs to.
The /plugins, /set, /theme, /model, and /add_model menus
Built on termflow's MenuBuilder (and TextInputBuilder for typed values),
exactly like Code Puppy's /agent, /mcp, /set, and /model menus:
alternate screen, a .preview panel on the right, .on_key for single-key
actions, .footer_hint for the key legend, markdown_style() for colours.
- Split it in two: a pure
build_plugins_menu(...)that returns the menu (so tests drive it headless, no terminal), and a thin async runner that owns the screen and callsmenu.runin a thread. - Every key mutates immediately and
replace_itemsredraws. No pending-changes state, no save/cancel pair. - Nothing prints to the console while the menu is open; the alternate screen would hide it. Show empty states and errors inside the menu as disabled rows.
- Esc and Ctrl-C close cleanly. They are not errors.
- A widget opened mid-run (including the inline
ask_userpicker) goes insideasync with host.full_screen(), which flushes streamed text and suspends the editor's input reader first, preserving its draft. Slash-command handlers already run with the editor suspended. Do not start a second input reader alongside the live editor. - Adding a plugin is not in the menu. It needs free text, so it stays
/plugins add. - Anything that is "edit named, validated fields" uses
field_menu.py: aFieldSourcesupplies rows, current values, validation, apply, and reset;FieldMenubuilds the widgets;run_flowis the loop./setand per-model settings are two sources, not two editors. Do not write a third editor. /setedits go throughCommandContext.set_setting/reset_setting, the same path as the typed command, so validation lives in one place.- Widget runners are a
Runnersvalue passed into the loops; tests pass scripted ones (tests/menu_script.py). Only the realwidget.run()one-liners areno cover. - Model sources live in
model_catalog.py. To add one (models.dev, a provider API), write a function returningCatalogModels and merge it incatalog(). The menu never talks to a source directly. - Per-model settings are the editable subset of core's
ModelSettings, declared once asModelSettingsFormwith descriptions and bounds. Extend the form, not the menu, to expose another setting.
Rendering
StreamRenderer owns text and thinking. It knows nothing about any specific
tool. Tool-specific output (shell previews, diffs, grep) is registered through
host.render by the plugin that owns the event. Match on event classes, never
on tool_name strings. Always flush the stream before printing anything else;
the host does this for renderers, so do not call console.print from inside an
on handler when a renderer would do.
Colours
/theme offers the unchanged default appearance and termflow.themes.PALETTES.
Do not define new palettes. Resolve brand roles with theme.color(...) for Rich;
theme.sgr(...) resolves raw ANSI itself. theme.current() returns a Termflow
palette or None for the original appearance. Termflow owns palette application
and reset; theme.use(...) leaves the terminal untouched in the default session.
Markdown keeps its original style by default and uses to_render_style() for a
selected palette. The preview renders a sample without OSC changes or persistence.
Heavy imports in theme.py stay lazy for the splash. Code uses the terminal
foreground and ANSI syntax colours through theme.syntax_theme(), shared by
streamed fences and theme previews. Default diff colours stay unchanged, while
bundled palettes use Termflow defaults.
File map
| File | Holds |
_cli.py | argument parsing, startup, --agent |
_app.py | the prompt loop and built-in /commands |
_session.py | conversation state, revision-checked saves, restore-only resume, per-run plugins |
sessions.py | resume command and background namer ownership; built-in step capture |
forks.py | /fork and /forks: history snapshot, background child sessions, deferred fork output |
session_browser.py | project/session browser using Termflow layout and terminal primitives |
_rendering.py | streaming Markdown and thinking |
plugins.py | PluginHost, hook names, event dataclasses |
plugin_loader.py | discovery, load, unload, reload; the /plugins subcommands |
plugin_menu.py | the /plugins full-screen menu (PluginMenu plus its runner) |
ask_user_menu.py | the built-in ask_user plugin: QuestionMenu, TerminalAnswerer, the transcript renderer |
screen.py | Screen, what host.full_screen() binds to during a prompt |
field_menu.py | the shared field editor (FieldSource, FieldMenu, Runners, run_flow) |
set_menu.py | /set: SettingsSource over CommandContext |
model_menu.py | /add_model: provider discovery, ModelSettingsSource, run_model_flow |
model_picker.py | /model: selection and completion of saved models |
model_catalog.py | model sources (genai-prices today) merged by catalog() |
model_settings.py | ModelSettingsForm, the editable subset of ModelSettings |
logfire.py | the default-enabled, locally configured Logfire plugin over core Instrumentation |
compaction.py | the built-in compaction plugin: harness FallbackCompaction([SummarizingCompaction, SlidingWindowCompaction]), /compact, the context alert |
commands.py | Command, the registry, completion |
usage_report.py | /usage, /cost, and the footer cost, derived from Session.messages |
status.py | the footer Status fields, StatusSegment, and the StatusLine row painter |
live_prompt.py | pinned editor lifecycle, completion worker, submission queue and menu handoff |
prompt_surface.py | scroll-region ownership, serialized transcript writes and changed-row painting |
prompt_transcript.py | bounded styled transcript tail for viewport replay |
prompt_resize.py | scoped resize notifications, without terminal IO in signal handlers |
prompt_buffer.py | pure draft editing, history navigation, search and cell-width wrapping |
prompt_completion.py | bounded daemon completion worker; no terminal ownership |
prompt_keys.py | keyboard decoder attachment only; no prompt-toolkit Application or renderer |
config.py | Settings, PluginSettings |
settings_store.py | the SQLite store under $XDG_CONFIG_HOME/pydantic-clai2/ |
project_settings.py | .clai/settings.json: the walk-up to the git root, validation, ProjectSettings |
repo_context.py | the built-in repo_context plugin over harness RepoContext |
speculation.py | the run.speculative_code_mode switch, Ctrl+X Ctrl+S toggle, session counters and pinned row |
speculative_mode.py | harness CodeMode wiring (native writes, read-only speculation allowlist, guidance), imported only while on |
eager_timing.py | eager run_code latency measurement and the nested-call id pattern |
sandbox_calls.py | events and ordering that render calls from inside run_code like direct calls; no harness imports |
theme.py | Existing brand roles, opt-in Termflow palette scope, color(), sgr() |
theme_picker.py | /theme picker over Termflow's bundled palettes |
spinners.py | the working-animation catalogue: builtins, plugin host.spinner, the user's spinners.json, Spinners |
spinner_frames.py | frame data for the Code Puppy cli-spinners pack |
spinner_picker.py | /spinner: animated picker, by-name selection with speed, init |
Keep files concise - we don't need any 10,000 line files. Single responsibility.
Testing
- When adding or modifying a CLAI2 CLI UX feature, test every affected UX feature
with your changes in a fresh tmux window before opening a PR. Run CLAI2 with
uv run clai2. Verify the actual terminal behavior against the intention of the user's request, not just the implementation or automated tests. pytest-anyio; real model calls are blocked globally.- Drive the shell with
TestModeland aConsole(file=StringIO()). - Test a hook by building a
PluginHost, registering a handler, and firing the event from the shell path that owns it. Assert the handler's effect (the cancelled turn, the rewritten text), not a mock call count. - Renderers get synthetic events. They must not need
Coderinstalled. - Cancellation: use a real
anyiocancel scope, order withEvents, no sleeps.
Settings and database compatibility
Before adding, removing, or renaming a setting, changing its type, meaning, or default, or changing the SQL schema, consider upgrades, downgrades, and branch switches. Different versions can share the same settings database.
- Add regression cases to
tests/test_settings_compatibility.pyand affected CLI tests using the previous stored format. Keep historical fixtures unchanged; do not regenerate them with current models or rewrite them to make a change pass. - Verify older databases load with documented defaults for missing settings and preserve existing preferences, plugin declarations, and model settings. Test migrations and repeated initialization for data preservation and idempotence.
- Preserve unknown saved setting names and their values when reading, editing other settings, resetting, and reopening. Unknown does not mean obsolete. Keep validation strict for known values, new writes, and plugin declarations.
- For renamed fields or changed types, meanings, or defaults, define the migration
or intentional behavior change and test it explicitly. Comparing only against
the current
Settings()defaults will not catch an unintended default change. - Verify rejected values and unsupported schema versions leave stored data intact. If a change introduces a migration, test failure rollback as well as success.
Local verification
Run from the repository root. CLAI shares the root uv.lock, .venv, and
Pyright configuration with Harness.
uv sync --locked --all-packages --group lint
uv run --no-sync ruff format --check .
uv run --no-sync ruff check .
PYRIGHT_PYTHON_IGNORE_WARNINGS=1 uv run --no-sync pyright pydantic-clai2/src pydantic-clai2/tests
uv run --no-sync pytest -p no:cacheprovider -c pydantic-clai2/pyproject.toml pydantic-clai2/testsDocs parity
README.md is the tour, PLUGINS.md is the plugin contract, this file is for
agents. A user-facing change updates the first two in the same PR. Plain
language, short sentences, no jargon a first-time plugin author would not know.
The resume browser is a focused exception to the single MenuBuilder convention:
it needs independently focused projects/sessions and two-line cards. Keep its
frame pure, inject keys/size/IO, use Termflow terminal ownership and layout, and
run through menu_worker. Metadata refreshes must preserve selection by ID.
History-changing plugins use await conversation.commit_messages, not the legacy
in-memory replace_messages, so exiting immediately after /compact is durable.
The interactive editor owns its layout explicitly. Do not reintroduce a
PromptSession renderer or mutate generated layout children. Transcript writes
go directly to the scroll region, never through an erase/redraw of the editor.
The hardware cursor stays hidden until release; the input cursor is a painted
reverse-video cell. Keep terminal mutations in PromptSurface, and detach the
key reader before a menu owns the screen. The remaining prompt-toolkit decoder
preserves paste and modified keys not yet exposed by Termflow's read_key.
Physical resize blanks the viewport and defers output until size notifications
have been quiet for 250 ms. Rebuild from TranscriptBuffer, not guessed old row
coordinates or cursor reports. Never send erase-scrollback (CSI 3 J). Keep editor
height changes separate from physical resize, preserve the draft, and close the
resize output spool on both normal handoff and failure. SIGWINCH only marks the
resize and schedules a paint; the signal handler must not perform terminal IO.
The ask_user picker is an inline exception to the full-screen menu convention.
It borrows the released PromptSurface while the editor is suspended, retaining
the shared transcript for resize replay. Keep its numbered choices and Enter
toggles; do not reintroduce alternate-screen switching or Space-to-toggle.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

