ZenAgent
A minimal coding agent written from scratch in pure Python: one interface for
streaming LLM calls, an agent loop that is an async generator, and a terminal
harness. No vendor SDKs, no agent framework — httpx is the only runtime
dependency of the kernel.
Named after the Zen of Python: explicit contracts, flat layers, and one obvious
way to do things. (Renamed from pyagent; there is no compatibility shim.)
Built as a learning project and as the small, hackable Python kernel that the
ecosystem is missing (in the spirit of pi-ai + pi-agent-core).
Repo: https://github.com/encyc/zen-agent · PyPI package: zenagent-py (imports as zenagent).
Docs
Full documentation — architecture, usage guides, internals (source tour), contracts and the
feature map — lives in docs/. Start with docs/architecture.md.
Release notes: CHANGELOG.md; the tool-kernel gap study and the next roadmap
live under docs/research/omp-cli-study/.
Install
# install the published CLI
uv tool install "zenagent-py[cli]" # or: pip install "zenagent-py[cli]"
zen --help # imports as `zenagent`; needs Python 3.11+
# work on the project itself
uv sync --extra cli # kernel + interactive extras (prompt_toolkit, rich)
The interactive TUI checks PyPI at most once an hour for a newer zenagent-py
and prints one quiet line in the transcript when one exists; -p/--json
never check. Disable it with ZENAGENT_NO_UPDATE_CHECK=1.
Requires Python 3.15+ (the project targets the current uv toolchain; any
3.11+ should work for the kernel itself).
API keys
Read from the environment variable named by each provider in
zenagent/catalog.yaml:
| provider | env var |
|---|---|
zai-coding-cn |
ZAI_CODING_CN_API_KEY |
deepseek |
DEEPSEEK_API_KEY |
opencode-go |
OPENCODE_API_KEY |
Usage
# one-shot, exits 0 on success and 1 when the provider failed
uv run zen -p "explain what this repo does"
# pick a model / reasoning level; allow write + execute tools
uv run zen -p "create /tmp/x/hello.txt, then read it back" -m deepseek-flash --yes --thinking high
# attach images (needs a vision model; deepseek-flash is one)
uv run zen -p "what is in this picture?" --image shot.png
# guardrails and reproducibility
uv run zen -p "..." --max-turns 20 --max-cost 0.05 --no-context-files
uv run zen -p "analyze this" --read-only --trace run.jsonl --cwd /path/to/project
# runtime modes: plan = read-only, ask = confirm (default), full = automatic
uv run zen -p "investigate and propose a plan" --mode plan
uv run zen -p "apply the fix" --mode full
# structured output (a single JSON object; no streamed text)
uv run zen -p "say hi" --json
# interactive REPL
uv run zen
Runtime modes: plan (read-only; the agent investigates and proposes a plan),
ask (default: write/execute tools need confirmation), full (run write/execute
tools without asking; catastrophic commands still need --allow-dangerous).
--yes means full, --read-only means plan, and /mode plan|ask|full
switches mid-session. Session display names come from the first question; rename
with /rename <name> (or r in the TUI session picker).
Write guards are independent of the mode: full/--yes never auto-approve a
write outside the project root, a write to a credential store
(~/.zenagent, ~/.ssh, …) or a destructive shell command. Interactive runs
confirm them; headless runs refuse and name the flag
(--allow-outside PATH, --allow-protected, --allow-destructive). Every
successful write is snapshotted per project; /restore puts the last run's
files back and skips anything you edited afterwards.
Diagnostics
uv run zen doctor # keys, catalog, config, session dir, terminal
Extending the agent (file-based, no restart needed)
| What | Where | How it shows up |
|---|---|---|
| Skills | .zenagent/skills/<name>/SKILL.md (project) or ~/.zenagent/skills/ |
listed in the prompt; the model loads one with read_skill |
| Prompt templates | .zenagent/commands/<name>.md |
/name args expands and sends it |
| Extensions | extensions = ["path.py"] in config or --extension |
registers tools, slash commands, prompt text, event hooks |
| MCP servers | ~/.zenagent/mcp.json |
tools exposed as mcp__<server>__<tool> |
| Custom providers/models | [providers.custom.<id>] in config.toml |
usable with -m <model> |
Servers that are down at start are retried before each prompt (/mcp shows
state, /mcp retry forces it), and /reload re-discovers skills, templates and
extensions mid-session.
Library / SDK
from zenagent.agent import Agent
from zenagent.tools import ALL_TOOLS
agent = Agent(
model=model, # a zenagent.providers.catalog.Model
system_prompt="be brief",
tools=list(ALL_TOOLS), # or your own AgentTool objects
api_key="...",
)
await agent.prompt("explain this repo") # events via agent.subscribe(fn)
See examples/README.md for a headless run, a custom tool with a fake stream
(offline), a direct MCP call, and a zen rpc client. For embedding with the full
app layer (MCP, skills, sessions), use Runtime + ChatController — see
examples/embed.py.
REPL commands
| command | effect |
|---|---|
/help |
list commands |
/exit |
leave (Ctrl+D also works) |
/clear |
forget the conversation |
/model [id] |
show / switch model (next turn) |
/thinking [level] |
off / low / medium / high / max |
/mode [plan|ask|full] |
show or switch the runtime permission mode |
/compact |
summarize the session into a new session file |
/undo |
drop the last turn (writes a new session file) |
/restore [list|n] |
restore files changed by the last run (or list recent runs) |
/sessions [all] |
list this project's sessions (or all projects) |
/rename <name> |
rename the current session |
/fork <n> |
branch a new session at user message n |
/import <path> |
load a session file and continue it |
/image <path> |
attach an image for the next prompt |
/skills |
list available skills |
/mcp |
MCP server status; /mcp retry reconnects |
/reload |
re-discover skills, templates, extensions |
/theme <name> |
default / mono / solarized / a JSON file |
/hotkeys |
show active keybindings |
/pin [on|off] |
pin/unpin the current session |
/new |
start a new session |
/settings |
show model, prefs and paths |
/search [query] |
web search, or show the provider |
/doctor |
run environment checks |
/todo |
print the session todo list |
/jobs [kill <id>] |
list or kill background jobs |
/copy [text] |
copy to the system clipboard |
/btw <question> |
ask a side question without touching the transcript |
/shake [tools|images|thinking|all] |
drop heavy content into a new session |
/handoff |
write a handoff markdown, then compact |
/add-dir /remove-dir /dirs |
track extra workspace roots |
/fresh |
reset display state |
/trace |
show the trace path (when started with --trace) |
Command arguments complete too (Tab): /mode → plan/ask/full, /thinking → levels,
/model → catalog ids. In the TUI the same values appear as you type, and a bare
/mode or /thinking opens a selector.
While the agent is working, anything you type is queued as steering input rather than starting a second run. Ctrl+C aborts the current run cooperatively.
Configuration
Optional ~/.zenagent/config.toml, plus a committable <project>/.zenagent/config.toml
that layers on top (themes, keybindings and MCP live in their own files):
default_model = "deepseek-flash"
[providers.deepseek]
base_url = "https://mirror.example/v1" # optional
api_key_env = "MY_DEEPSEEK_KEY" # optional
# define a provider from scratch
[providers.custom.local]
base_url = "http://127.0.0.1:8080/v1"
api_key_env = "LOCAL_API_KEY"
adapter = "openai-completions"
[[providers.custom.local.models]]
id = "my-model"
context_window = 32768
max_tokens = 4096
reasoning = false
extensions = ["~/work/my-extension.py"]
[tools]
allow = ["read_*", "grep"] # never ask for these
deny = ["write_file"] # always refuse
# global layer only (a project-layer [task]/[safety]/[backup] is rejected)
[safety]
allow_outside = ["~/scratch"] # writes allowed outside the workspace
allow_destructive = false
allow_protected = false
[backup]
enabled = true
max_file_bytes = 2097152 # larger files are not snapshotted
keep_runs = 50
[task]
max_turns = 300 # subagent model turns before it is cut off
keep_transcripts = 64 # per-session subagent transcripts retained
max_transcript_bytes = 33554432 # total bytes of retained transcripts per session
max_transcript_file_bytes = 8388608 # one transcript past this is cut and marked
Themes: ~/.zenagent/keybindings.json ({"newline": "escape,enter"}) and
--theme mono. MCP servers: ~/.zenagent/mcp.json with
{"servers": {"name": {"command": [...]} | {"url": "...", "headers": {...}}}}.
Costs are estimated from the catalog's unit prices (input, output, and cache
read/write separately). DeepSeek bills peak/off-peak; the catalog has no time
dimension, so peak rates are used — treat usage.cost as a conservative upper
bound. Providers with subscription pricing (zai, opencode-go) carry 0.
Sessions are append-only JSONL scoped to the project they were started in:
~/.zenagent/projects/<project-key>/sessions/. /sessions, --resume latest,
--list-sessions and zen export latest default to the current project; add
all/--all-projects for a cross-project view. Sessions from before project
scoping stay in ~/.zenagent/sessions/ as read-only legacy entries and can be
moved with zen sessions adopt PATH. --resume continues the most recent one. Destructive-looking operations never rewrite a file: /undo,
/fork and /compact all write a new session and leave the old one intact. The file format is exactly the public message model, so it
can be inspected with jq or replayed elsewhere.
Architecture
cli/ REPL, headless entry point, rendering, permission gate, /compact ← harness
agent/ loop (pure function) + Agent (state, events, steering, abort) ← runtime
providers/ openai-completions (+ zai/deepseek dialects), anthropic-messages ← client
shared SSE transport, compat flags, packaged model catalog
types.py messages, content blocks, usage ← vocabulary
events.py stream events, agent events, abort token
Dependencies point strictly downward: providers knows nothing about agent,
agent knows nothing about cli. The kernel (types, events, providers,
agent, tools) imports only httpx; prompt_toolkit/rich live in the CLI
extra.
Contracts worth knowing
StreamFnnever raises. Every failure (network, 4xx/5xx, parse error, cancellation) becomes a terminaldone/errorevent.safe_streamenforces this for any stream function, including injected fakes.- Cancellation is a protocol state, not an exception. The transport raises
Abortedfor cooperative cancellation;asyncio.CancelledErroris reserved for real task cancellation. - Tools cannot crash the loop. Unknown tools, invalid arguments, blocked
calls, exceptions, and truncated (
stop_reason == "length") calls all becomeis_errortool results. Truncated calls are never executed and always get a result, so the transcript never carries a danglingtool_call. - Every event carries a
partialsnapshot, so the UI never assembles deltas itself; assembly belongs to the adapter and happens once. - Tools declare their approval tier (
read/write/exec); the kernel does not enforce it — the harness permission gate does. Plan/--read-onlytherefore allowweb_search/fetchand MCP tools withreadOnlyHint: true, while the kernel stays policy-free. - Every tool result goes through one budget. Over 40 KB keeps the head and tail with a uniform truncation notice; overlong lines are middle-truncated. Per-tool caps remain the first line of defense, MCP/extension tools get the same safety net.
Tools
read_file (paged: 400 lines per call by default, offset/limit for the next
page, line_numbers for inspection), write_file (refuses writing back a
truncated read page, preserves the executable bit), edit_file
(exactly-one-match replacement, replace_all), list_dir, bash (merged
output, 30 KB head+tail truncation, process-group kill on timeout/abort,
non-interactive environment, optional cwd), glob (root .gitignore aware,
sort=mtime), grep (ripgrep when available, Python fallback), web_search
(provider chain), fetch (URL → readable text), wait (background-job
results), todo (session-scoped checklist with a TUI HUD and /todo editor),
ask (structured questions to the user; TUI dialog / REPL prompt / graceful
headless degrade), task (read-only subagent delegation with its own transcript
and cost), checkpoint/rewind (the model compacts its own exploration into a
report), lsp (diagnostics/symbols/references/definition/rename through
pyright or typescript-language-server), and github (read-only gh ops; only
registered when gh is installed). Oversized results spill to the session
artifacts/ directory. bash supports async background jobs; job results are
collected with wait and managed with /jobs. Write tools are fenced to the
project root by default (outside/protected writes need confirmation); successful
writes are recorded in a project-scoped snapshot log and can be put back with
/restore.
Tests
bash scripts/check.sh # the gate: sync + ruff + pytest (CI runs exactly this)
uv run pytest -q # fully offline: fake stream fns + replayed SSE fixtures
./scripts/eval.sh # behavioral evals against a real model (opt-in, costs money)
Behavioral evals (real model, small cost) check the agent rather than the code; they are separate from the offline gate:
./scripts/eval.sh --list # case ids
./scripts/eval.sh --model deepseek-flash --repeats 3
scripts/capture_sse.py records real provider traffic into
zenagent/providers/fixtures/ so the parser can be checked against the wire; the
replay tests skip when a capture is absent.
Releasing
zenagent-py ships from a tag. Everything before the tag runs offline:
bash scripts/check.sh # lint + tests
bash scripts/release-check.sh # build sdist/wheel, install both ways, run the CLI
git tag vX.Y.Z
git push origin main vX.Y.Z # the publish workflow re-runs the gate, then uploads
release-check.sh is the packaging gate: it asserts the version is consistent
(pyproject.toml, __version__, and the tag on HEAD), that the sdist and wheel
carry no dev or stray files, that a kernel-only install works (pip install zenagent-py), and that a [cli] install starts (zen --version, TUI imports,
search providers, a settings round-trip in an isolated $HOME). It tests the
requires-python floor by default; pass --python 3.13 for another
interpreter, or --skip-build to verify the existing dist/. It never uploads
and writes only under a temp dir.
.github/workflows/publish.yml runs the same gate before uploading. A manual run
is always a dry run — it builds and verifies but cannot publish; only a v* tag
push reaches PyPI:
gh workflow run publish.yml # optional dry run on CI (build + verify only)
Status
v1 (milestones 1–4), S1–S3 and the Textual TUI are complete; the offline suite
and recorded live captures are the gate (docs/testing.md). Implemented:
interactive REPL + TUI, MCP, skills/templates/extensions, RPC/SDK,
export/usage/telemetry, four protocol adapters, runtime modes
(plan/ask/full), session display names + rename, and TUI session
management (new/fork/delete/export/rename), hand-slip guardrails (workspace fence,
protected paths, destructive-command confirmations, write snapshots + /restore).
The TUI sidebar (ctrl+b) is a
drag-resizable work panel: the full todo checklist, running subagents and
background jobs (click one for its log), plus a context-composition section
with a per-slice breakdown overlay. A subagent row opens its read-only
transcript, which survives --resume. The P1/P2 completion slice (see
docs/superpowers/plans/2026-11-01-p1-p2-completion.md) added the tool
registry, LSP/GitHub/checkpoint-rewind tools, /plan-review, /hotkeys,
/usage, /mcp add, the /todo editor, read-file grouping, clipboard/editor
keys, and the command batch listed above.
Not yet: OS-level sandboxing. The fence and snapshots protect against agent
mistakes, not against malicious repositories or prompt injection (threat model #2).
Metadata
Release files for zenagent-py 0.7.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| zenagent_py-0.7.2.tar.gz | 374.0 kB | Without provenance | Details for zenagent_py-0.7.2.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| zenagent_py-0.7.2-py3-none-any.whl | Python 3 | none | any | Details for zenagent_py-0.7.2-py3-none-any.whl |
Total release size: 825.7 kB
Release files / zenagent_py-0.7.2.tar.gz
| Download URL | zenagent_py-0.7.2.tar.gz |
|---|---|
| Size | 374.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5698294439609df94305dc1ab91a0f9c3c5c949af6a43500b6b963df617bd26b
|
|
BLAKE2b-256 checksum How to use checksums |
3cef7296d783f2cb95c6c031fcfe0b083e610b5b7471f6ed355b8fc630364b8e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Release files / zenagent_py-0.7.2-py3-none-any.whl
| Download URL | zenagent_py-0.7.2-py3-none-any.whl |
|---|---|
| Size | 451.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
5f848b47c4086d61daf66c753323b7e5c444554f0a5e64b6d86fb5a1ff38b646
|
|
BLAKE2b-256 checksum How to use checksums |
cdabfc166312dd2c8b52e73628ae24deefa71714d75aad69ba208b5d641a3d46
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Security for zenagent-py 0.7.2
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.