Skip to main content

ZenAgent

PyPI CI Python

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 a day 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).

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)
/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

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

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

  • StreamFn never raises. Every failure (network, 4xx/5xx, parse error, cancellation) becomes a terminal done/error event. safe_stream enforces this for any stream function, including injected fakes.
  • Cancellation is a protocol state, not an exception. The transport raises Aborted for cooperative cancellation; asyncio.CancelledError is reserved for real task cancellation.
  • Tools cannot crash the loop. Unknown tools, invalid arguments, blocked calls, exceptions, and truncated (stop_reason == "length") calls all become is_error tool results. Truncated calls are never executed and always get a result, so the transcript never carries a dangling tool_call.
  • Every event carries a partial snapshot, 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-only therefore allow web_search/fetch and MCP tools with readOnlyHint: 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), write_file (refuses writing back a truncated read page), edit_file (exactly-one-match replacement), list_dir, bash (merged output, 30 KB head+tail truncation, process-group kill on timeout/abort, non-interactive environment, optional cwd), glob, 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), ask (structured questions to the user; TUI dialog / REPL prompt / graceful headless degrade), task (read-only subagent delegation with its own transcript and cost) and artifact spill for oversized tool results. bash supports async background jobs; job results are collected with wait.

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). The TUI sidebar (ctrl+b) is a work panel: the todo checklist, running subagents and background jobs; a subagent row opens its read-only transcript, which survives --resume. Not yet: OS-level sandboxing.

Metadata

Release files for zenagent-py 0.4.4

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for zenagent-py 0.4.4
File Size Uploaded Provenance Actions
zenagent_py-0.4.4.tar.gz 273.5 kB Without provenance Details for zenagent_py-0.4.4.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for zenagent-py 0.4.4
File Interpreter ABI Platform Actions
zenagent_py-0.4.4-py3-none-any.whl Python 3 none any Details for zenagent_py-0.4.4-py3-none-any.whl

Total release size: 614.1 kB

Release files / zenagent_py-0.4.4.tar.gz

Download URL zenagent_py-0.4.4.tar.gz
Size 273.5 kB
Tags Source
SHA-256 checksum
How to use checksums
3533b1a22c3b17e5f6b1d1b9c5c86b82ef798b4356aff1ab79adc12f2d0610c6
BLAKE2b-256 checksum
How to use checksums
28f5435454a9109ec8605f4068c2e3b28d75c7faf5c1684f30e96ef4ca465f60
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.4.4-py3-none-any.whl

Download URL zenagent_py-0.4.4-py3-none-any.whl
Size 340.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
b59abe271b2b789f0bce7e07de81ca3478ab9cd0be1d809048dd158978501230
BLAKE2b-256 checksum
How to use checksums
52890d2fc7ea925e00752f8a29fab0dadb94b00e648f884e3b8dc8315d802e7c
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.4.4

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.

Release history Release notifications | RSS feed

This release

0.4.4 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page