A lightweight agent harness built with Textual and a configurable multi-provider backend
Project description
PyAgent
PyAgent is a lightweight coding agent with a terminal UI, streaming model responses, tool use, layered project instructions, and switchable model profiles.
It is built with Textual and supports both native Ollama chat endpoints and OpenAI-compatible /v1/chat/completions servers such as OpenAI and vLLM.
Quickstart
1. Install
pip install pyagent-harness
For the optional HTTP API server, install the API extra:
pip install pyagent-harness[api]
2. Configure a model
PyAgent looks for model profiles at:
~/.pyagent/profiles.json
Create a minimal Ollama profile:
{
"default_profile": "local-qwen",
"profiles": {
"local-qwen": {
"provider": "ollama",
"base_url": "http://localhost:11434",
"model": "qwen2.5-coder:7b"
}
}
}
Or a minimal OpenAI profile:
{
"default_profile": "openai-mini",
"profiles": {
"openai-mini": {
"provider": "openai",
"base_url": "https://api.openai.com/v1",
"model": "gpt-4.1-mini",
"api_key_env": "OPENAI_API_KEY"
}
}
}
Then run:
pyagent
You can also run one prompt and exit:
pyagent --prompt "Summarize this repository"
What PyAgent does
- Provides a streaming Textual TUI for chat-based coding work.
- Supports tool calling for shell commands, file listing/search, text search, file reads/writes/appends/edits, and arithmetic.
- Supports text-only mode by disabling model tool calling for a session.
- Uses Markdown rendering for assistant and tool messages, with a plain-text fallback for fenced code blocks containing very long lines so transcript content does not get clipped.
- Loads named model profiles from JSON for easy switching between local and remote endpoints.
- Supports Ollama natively and OpenAI-compatible providers through the OpenAI Python SDK.
- Loads layered always-on instructions from user-global and project-local
AGENTS.mdfiles, with.md/.skillskills available for explicit loading. - Supports persistent custom tools and skills under
~/.pyagent/, safe from package upgrades. - Includes optional single-shot CLI, HTTP API, Python client, and browser-hosted TUI modes.
Installation
Standard install
pip install pyagent-harness
Install with HTTP API support
pip install pyagent-harness[api]
The API extra installs FastAPI and Uvicorn for pyagent serve.
Developer install from a checkout
python -m pip install -e .
With API support:
python -m pip install -e '.[api]'
Non-editable local install:
python -m pip install .
With API support:
python -m pip install '.[api]'
If you only want dependencies without installing the package entry point:
pip install -r requirements.txt
Model profiles
PyAgent loads named model profiles from JSON. By default it reads:
~/.pyagent/profiles.json
Override that path with:
export PYAGENT_MODEL_PROFILES_PATH=/path/to/profiles.json
A sample profile file is included as models.example.json.
Profile file example
{
"default_profile": "local-qwen",
"profiles": {
"local-qwen": {
"provider": "ollama",
"base_url": "http://localhost:11434",
"model": "qwen2.5-coder:7b"
},
"openai-gpt4": {
"provider": "openai_compatible",
"base_url": "https://api.openai.com/v1",
"model": "gpt-4.1",
"api_key_env": "OPENAI_API_KEY"
},
"vllm-local": {
"provider": "vllm",
"base_url": "http://localhost:8000/v1",
"model": "Qwen/Qwen2.5-Coder-32B-Instruct",
"api_key_env": "VLLM_API_KEY"
}
}
}
Supported provider values:
ollamaopenai_compatibleopenaivllm
openai and vllm are treated as OpenAI-compatible providers.
OpenAI-compatible profiles use the OpenAI Python SDK with the Chat Completions API. PyAgent intentionally stays on /v1/chat/completions, not the newer Responses API, so it remains compatible with OpenAI-style servers such as OpenAI and vLLM.
API keys
Profiles can specify either:
api_key— inline secret valueapi_key_env— environment variable name to read at runtime
Using api_key_env is recommended. Inline secrets in config files age about as well as milk.
For local OpenAI-compatible servers that do not require authentication, omit both fields.
Extra headers and HTTP transport options
Profiles may include:
headers— extra HTTP headers to send with requestshttpx_kwargs— keyword arguments passed tohttpx.Clientfor OpenAI-compatible providers onlyhttp_kwargs— legacy alias forhttpx_kwargs; still accepted, but not recommended
If both httpx_kwargs and http_kwargs are present, httpx_kwargs wins.
Example:
{
"default_profile": "local-vllm",
"profiles": {
"local-vllm": {
"provider": "vllm",
"base_url": "https://localhost:8000/v1",
"model": "Qwen/Qwen2.5-Coder-32B-Instruct",
"httpx_kwargs": {
"verify": false
}
}
}
}
Fallback profile from environment
If the profile file does not exist, PyAgent creates an implicit default profile from environment variables:
PYAGENT_PROFILEPYAGENT_PROVIDERPYAGENT_MODELPYAGENT_BASE_URLPYAGENT_API_KEYPYAGENT_API_KEY_ENV
Running PyAgent
Interactive TUI
pyagent
Or as a module:
python -m pyagent
Select a saved profile and optionally override its model for the current session:
pyagent --profile local-qwen
pyagent --profile openai-gpt4 --model gpt-4.1-mini
Single-shot CLI
Run one prompt and exit:
pyagent --prompt "What files are in the current directory?"
Use a profile/model override:
pyagent --profile openai-gpt4 --prompt "Summarize README.md"
pyagent --profile openai-gpt4 --model gpt-4.1-mini --prompt "Review the current project"
Single-shot mode loads layered instruction context just like the TUI.
You can also load specific skills into the startup system prompt with --skills. Pass comma-separated scoped skill IDs (user:<path> or project:<path>). For backward compatibility, unscoped names resolve to user skills under ~/.pyagent/skills/ first:
pyagent --skills user:code-review.md,project:skills/testing.skill --prompt "Review this repository's testing strategy"
pyagent --skills code-review.md --prompt "Use my user code-review skill"
If any listed skill does not exist, PyAgent exits with an error. The --skills flag is currently supported only with --prompt.
Browser-hosted TUI
PyAgent can expose the Textual app in a browser through textual-serve:
pyagent web
Optional bind and model/profile overrides:
pyagent web --host 0.0.0.0 --port 8000
pyagent web --profile local-qwen --model qwen2.5-coder:7b
This serves the normal python -m pyagent app through a small web server.
HTTP API server
Install the API extra first:
pip install pyagent-harness[api]
Then start the server:
pyagent serve
Optional bind overrides:
pyagent serve --host 0.0.0.0 --port 8000
Endpoints:
GET /health— basic health checkPOST /run— run a single non-streaming agent turn
Example request:
curl -X POST http://127.0.0.1:8000/run \
-H 'Content-Type: application/json' \
-d '{
"message": "Summarize README.md",
"messages": [
{"role": "user", "content": "We already discussed installation."}
],
"profile": "local-qwen",
"model": "qwen2.5-coder:7b",
"cwd": ".",
"skills": ["code-review.md"]
}'
Example response:
{
"response": "...",
"profile": "local-qwen",
"provider": "ollama",
"model": "qwen2.5-coder:7b",
"context_files": ["~/.pyagent/AGENTS.md", "AGENTS.md"]
}
The API uses the same profile selection, model override, context loading, and optional skill validation as single-shot CLI mode. Skills may be scoped IDs such as user:code-review.md or project:skills/review.md; unscoped names resolve to user skills first. You may pass prior conversation history in the optional messages field on POST /run; PyAgent preserves its own active system prompt and ignores incoming system messages so runtime instructions cannot be overridden by API callers.
If FastAPI or Uvicorn are missing, pyagent serve exits with a clear error.
Python API client
PyAgent ships with a small synchronous HTTP client for the API. It uses the Python standard library, so the client does not require the server-side api extra just to make requests.
from pyagent.client import PyAgentClient
client = PyAgentClient("http://127.0.0.1:8000")
print(client.health())
result = client.run(
"Summarize README.md",
messages=[{"role": "user", "content": "We already discussed installation."}],
profile="local-qwen",
model="qwen2.5-coder:7b",
cwd=".",
skills=["code-review.md"],
)
print(result.response)
print(result.profile, result.provider, result.model)
print(result.context_files)
Client details:
PyAgentClient.health()returns the decoded/healthJSON payload.PyAgentClient.is_healthy()returnsTrueorFalsewithout raising on connection failures.PyAgentClient.run(...)returns a typedRunResponseobject.PyAgentClientErroris raised for HTTP errors, invalid JSON responses, connection failures, and timeouts.- The default base URL is
http://127.0.0.1:8000.
Instructions, skills, and project context
PyAgent layers always-on instruction files into the active system prompt and keeps skills discoverable for explicit loading or discovery by the agent.
Loaded first, as user-global context:
~/.pyagent/AGENTS.md
Loaded next, from the current project:
AGENTS.md
Available as skills, but not loaded into the system prompt by default:
~/.pyagent/skills/**/*.md~/.pyagent/skills/**/*.skill*.skillskills/**/*.mdskills/**/*.skill
Skills are plain text guidance files. Users can explicitly load skills into the system prompt with /skills load <id-or-path> in the TUI, --skills in single-shot CLI mode, or the API skills field.
Use /context in the TUI to inspect loaded instruction sources and context size. Use /skills list to inspect available skills. Use /reload_context to rescan AGENTS.md files and any skills explicitly loaded into the system prompt.
Custom system prompt
PyAgent stores the base system prompt in a text file. By default:
~/.pyagent/system_prompt.txt
On first run, PyAgent creates the file automatically if it does not already exist.
Override the location with:
export PYAGENT_SYSTEM_PROMPT_PATH="$HOME/.config/pyagent/my_prompt.txt"
pyagent
Or edit the default file directly:
mkdir -p ~/.pyagent
$EDITOR ~/.pyagent/system_prompt.txt
Manage reusable system prompts from the CLI with the plural prompts subcommand:
pyagent prompts install ./coder.md
pyagent prompts list
pyagent prompts show coder.md
pyagent prompts use coder.md
pyagent prompts remove coder.md
Installed prompt files live under ~/.pyagent/system_prompts/. pyagent prompts use <name> copies the selected prompt to the active system prompt path (~/.pyagent/system_prompt.txt by default, or PYAGENT_SYSTEM_PROMPT_PATH when set) and leaves the installed copy in place.
Notes:
/promptshows the currently active system prompt in the TUI.- The system prompt is loaded when the conversation is initialized or reset. After editing or switching it, use
/clearto start a fresh conversation with the updated prompt. - User and project instruction files are layered onto the base system prompt automatically.
Tools
PyAgent has two tool layers:
- Built-in tools shipped with the package.
- External user tools under
~/.pyagent/tools/.
Built-in tools include:
bashlist_filesfind_filessearch_textread_filewrite_fileappend_fileedit_file
Tool calling is enabled by default. Disable all model tool calling for a session with:
export PYAGENT_TOOLS_ENABLED=false
Disable the built-in tool set while still allowing externally installed tools with:
export PYAGENT_BUILTIN_TOOLS_ENABLED=false
export PYAGENT_USER_TOOLS_ENABLED=true
Disable external user-tool discovery while keeping built-ins available with:
export PYAGENT_USER_TOOLS_ENABLED=false
Disable only the bash tool with:
export PYAGENT_BASH_ENABLED=false
When PYAGENT_TOOLS_ENABLED=false, PyAgent does not advertise tools to the model and adds a system instruction telling it not to call tools. When PYAGENT_BUILTIN_TOOLS_ENABLED=false, built-ins are omitted from the registry; external tools remain available if PYAGENT_USER_TOOLS_ENABLED=true.
External user tools
User tools live under:
~/.pyagent/tools/
Each user tool is a standalone Python file run through uv. Dependencies are declared inline using PEP 723 and installed into an isolated environment on first invocation, so adding a tool does not bloat the core PyAgent install. Miraculous, really.
Every user tool must implement two CLI subcommands:
<runner> run <script> describe— print a JSON manifest withname,description,parameters, and optionalversion.<runner> run <script> invoke --args <json>— read JSON arguments passed inline as<json>, print the result to stdout, and exit non-zero with stderr on failure.
By default, <runner> is uv. Override it with PYAGENT_TOOL_RUNNER if needed.
Scaffold a new tool from inside the TUI:
/tools new <name>
Or install an existing tool from the CLI:
pyagent tools install ./my_tool.py
pyagent tools install https://example.com/my_tool.py --name my_tool.py
Then reload tools in the TUI:
/tools reload
User tool skeleton
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = ["click"]
# ///
import json
import click
@click.group()
def cli():
pass
@cli.command()
def describe():
click.echo(json.dumps({
"name": "my_tool",
"description": "What this tool does — sent verbatim to the model.",
"parameters": {
"type": "object",
"properties": {"input": {"type": "string"}},
"required": ["input"],
},
"version": "1",
}))
@cli.command()
@click.option("--args", "args_json", required=True, help="Stringified JSON object containing the tool arguments.")
def invoke(args_json):
args = json.loads(args_json)
click.echo(my_logic(**args))
if __name__ == "__main__":
cli()
A complete reference tool lives at examples/tools/search_hf_datasets.py. Install it with:
pyagent tools install examples/tools/search_hf_datasets.py
Then run /tools reload in PyAgent. UV installs huggingface_hub and datasets for that script on first invocation.
External tool lifecycle
- New or changed scripts:
/tools reloadrescans the directory and rebuilds the registry. - Schema cache: stored at
~/.pyagent/tools/.cache/manifests.json, keyed by path, mtime, and size. - Disable a tool:
/tools disable <name>orpyagent tools disable <tool_file>moves it to~/.pyagent/tools/disabled/. - Re-enable a tool:
/tools enable <name>orpyagent tools enable <tool_file>. - Locate a script:
/tools open <name>prints its absolute path. - Name collisions: built-ins win when built-in tools are enabled.
/toolsreports colliding external scripts so you can rename them. IfPYAGENT_BUILTIN_TOOLS_ENABLED=false, those built-in names are no longer reserved and an external tool may register the same name. - Broken scripts: timeout, non-zero
describe, and malformed JSON are listed under "Broken external tools" and skipped. - Missing
uv: external tools are disabled at startup with a clear banner; built-ins still work unlessPYAGENT_BUILTIN_TOOLS_ENABLED=false.
Trust boundary
~/.pyagent/tools/ is user-owned. PyAgent enforces wall-clock timeouts but does not otherwise sandbox these scripts. Treat any tool you install there as code you have chosen to run.
Managing user skills, tools, and extensions from the CLI
PyAgent can install, list, and remove user-managed skills and tools under ~/.pyagent/, and manage the on-disk lifecycle of extensions under ~/.pyagent/extensions/.
pyagent prompts list
pyagent prompts install ./coder.md
pyagent prompts install https://example.com/coder.md --name coder.md
pyagent prompts show coder.md
pyagent prompts use coder.md
pyagent prompts remove coder.md
pyagent skills list
pyagent skills install ./review.md
pyagent skills install https://example.com/review.md --name review.md
pyagent skills remove review.md
pyagent tools list
pyagent tools install ./my_tool.py
pyagent tools install https://example.com/my_tool.py --name my_tool.py
pyagent tools disable my_tool.py
pyagent tools enable my_tool.py
pyagent tools remove my_tool.py
Notes:
- Prompts are installed under
~/.pyagent/system_prompts/and must use.txtor.md. pyagent prompts use <name>copies the installed prompt to the active system prompt path; it does not remove the installed copy.- Skills are installed under
~/.pyagent/skills/and must use.mdor.skill. - Tools are installed under
~/.pyagent/tools/and must use.py. pyagent tools disable <tool_file>moves a tool into~/.pyagent/tools/disabled/;pyagent tools enable <tool_file>moves it back. Use filenames or paths, with or without.py.- CLI tool enable/disable changes disk state only; restart PyAgent or run
/tools reloadin an active TUI to apply it there. - Use
--forcewithinstallto overwrite an existing file. - Installed tools are marked executable automatically.
Managing extensions from the CLI
The pyagent extensions subcommands operate on the on-disk lifecycle of extensions — they move extension packages between ~/.pyagent/extensions/ (enabled) and ~/.pyagent/extensions/disabled/ (disabled), or delete them outright. There is no live agent session, so they do not touch the in-memory bus; use the TUI /extension load / /extension unload commands for that.
pyagent extensions list # show enabled and disabled extensions
pyagent extensions enable <name> # move an extension out of disabled/ so it auto-loads on next session
pyagent extensions disable <name> # move an extension into disabled/ so it does NOT auto-load on next session
pyagent extensions remove <name> # permanently delete an extension (checked in both enabled and disabled/)
Notes:
- An extension that is enabled (in
~/.pyagent/extensions/<name>/) is auto-loaded on the next session start. - An extension that is disabled (in
~/.pyagent/extensions/disabled/<name>/) is skipped at startup; its skills and tools are not discovered. Useenableto restore it. - After running
enable/disablefrom the CLI, a running TUI session will not see the change until you run/extension reload(or restart). The CLI writes to disk; it does not talk to a live session. enableonly un-disables an extension that was previouslydisabled. If an extension is already enabled,enablereports that it was not found in the disabled directory.removedeletes the extension package or file from disk and cannot be undone. It checks both the enabled and disabled directories.
Recommended user directory layout:
~/.pyagent/
├── profiles.json # named model profiles
├── system_prompt.txt # active base system prompt
├── system_prompts/ # reusable system prompts (*.txt, *.md)
├── AGENTS.md # optional user-global agent instructions
├── skills/ # user-global skills (*.md, *.skill)
└── tools/ # user tools, one UV script per tool
├── <my_tool>.py
├── disabled/ # listed in /tools but not registered
└── .cache/manifests.json # automatic schema cache
TUI reference
Runtime slash commands
/clear— clear the conversation/help— show command help/tools— show tool status, built-ins, external tools, and broken/disabled scripts/tools on— enable model tool calling for the current session/tools off— disable model tool calling for the current session/tools reload— rescan~/.pyagent/tools/and rebuild the tool registry; also available as/reload_tools/tools new <name>— scaffold a starter UV-script tool at~/.pyagent/tools/<name>.py/tools enable <name>— move a script out of~/.pyagent/tools/disabled//tools disable <name>— move a script into~/.pyagent/tools/disabled//tools open <name>— print the absolute path to a tool script/profiles— list saved profiles, including current/default markers and auth hints/profiles reload— reload profiles from disk/reload_profiles— reload profiles from disk/profile— show the active profile/profile <name>— switch to a saved profile/profile add <name> provider=<provider> model=<model> [base_url=<url>] [api_key_env=<ENV>] [api_key=<KEY>] [default=true|false] [switch=true|false] [header.<Name>=<Value>]— create or update a profile from the TUI/model— show the active model/model list— ask the current endpoint for available models, if supported/model <name>— override the current profile's model for this session/status— show current configuration, including the agent tool-loop max-iteration setting/max_iterations <n|-1>— set the maximum tool-loop iterations for the current session;-1means infinite/cwd— show current working directory/history— show recent prompt history/history search <text>— search saved prompt history for matching prompts/context— show loaded instruction sources and context size/skills list— show available user and project skills plus session load state/skills load <id-or-path>— load a user or project skill into the system prompt for this session/skills unload <id-or-path>— unload a previously loaded skill from the system prompt/prompt— show the active system prompt/reload_context— reload user-global/projectAGENTS.mdfiles and explicitly loaded skills, reporting added/removed files/extension list(or/extensions list) — show extensions in~/.pyagent/extensions/, including disabled ones/extension reload— re-scan and reload all extensions/extension new <name>— scaffold a starter extension/extension load <name>— move (if disabled) into enabled/ and load into the bus this session/extension unload <name>— remove from the bus this session and move intodisabled/so it stays unloaded across restarts/extension enable <name>— move an extension out ofdisabled/so it auto-loads next session (does not load into the running bus)/extension disable <name>— move an extension intodisabled/so it does not auto-load next session (unloads it from the running bus if loaded)/extension remove <name>— permanently delete an extension from disk (checked in both enabled and disabled/)/logging on|off— enable or disable session logging under~/.pyagent/logs//debug— show whether the debug pane is currently on or off/debug on|off— show or hide the debug pane
Changing tool mode at runtime resets the current conversation so the updated system prompt is applied cleanly.
Unknown slash commands may suggest a close match. For example, /stats may suggest /status.
Profile creation from the TUI
Create or update profiles with /profile add. Quote values containing spaces.
/profile add local-14b provider=ollama model=qwen2.5-coder:14b switch=true
/profile add openai-mini provider=openai model=gpt-4.1-mini api_key_env=OPENAI_API_KEY default=true
/profile add vllm-qwen provider=vllm model="Qwen/Qwen2.5-Coder-32B-Instruct" base_url=http://localhost:8000/v1 api_key_env=VLLM_API_KEY header.X-Project=PyAgent
Keyboard shortcuts
Enter— send the current promptShift+Enter— insert a newline in the prompt boxCtrl+P/Ctrl+N— move through prompt history↑/↓— scroll the chat transcriptPgUp/PgDn— page through the chat transcriptHome/End— jump to the top or bottom of the chat transcriptCtrl+L— clear the conversationCtrl+D— toggle the debug paneCtrl+C— quit the app
Configuration reference
Core environment variables
PYAGENT_PROFILE— default profile name to selectPYAGENT_MODEL_PROFILES_PATH— path to the JSON profile file, overriding~/.pyagent/profiles.jsonPYAGENT_SYSTEM_PROMPT_PATH— path to the system prompt text file, overriding~/.pyagent/system_prompt.txtPYAGENT_REQUEST_TIMEOUT— request timeout in secondsPYAGENT_MAX_ITERATIONS— maximum tool loop iterations per user turn;-1means infinitePYAGENT_MAX_HISTORY_MESSAGES— number of recent non-system messages to keepPYAGENT_MAX_PREVIOUS_TOOL_RESULT_CHARS— maximum characters to send for older tool-result messages; the current trailing tool-result block is kept intact, and0or-1disables maskingPYAGENT_STREAM_BATCH_INTERVAL— UI flush interval in secondsPYAGENT_USER_DIR— root for user-managed prompts, tools, skills, logs, and user-globalAGENTS.md; default~/.pyagent. Model profiles usePYAGENT_MODEL_PROFILES_PATH.
Tool environment variables
PYAGENT_TOOLS_ENABLED— enable or disable all model tool calling for the session; defaulttruePYAGENT_BUILTIN_TOOLS_ENABLED— register built-in tools (bash, file tools, search/edit tools); defaulttruePYAGENT_BASH_ENABLED— enable or disable bash execution; defaulttrue(whenfalse, the bash tool remains registered but returns a disabled-by-configuration error)PYAGENT_BASH_READONLY_MODE— restrict bash to read-only command prefixesPYAGENT_BASH_TIMEOUT_DEFAULT— default bash timeout in secondsPYAGENT_BASH_BLOCKED_SUBSTRINGS— comma-separated dangerous bash fragments to blockPYAGENT_BASH_READONLY_PREFIXES— comma-separated allowed prefixes in read-only modePYAGENT_USER_TOOLS_ENABLED— discover and register external tools under~/.pyagent/tools/; defaulttruePYAGENT_USER_TOOL_TIMEOUT— wall-clock timeout in seconds for each external tool invocation; default60PYAGENT_USER_TOOL_DESCRIBE_TIMEOUT— wall-clock timeout for thedescribeschema fetch; default10PYAGENT_TOOL_RUNNER— executable used to run external tools; defaultuv
These tool environment variables apply to the TUI, single-shot CLI mode, and agents created by the HTTP API server process.
Fallback profile environment variables
Used when no profile file exists:
PYAGENT_PROVIDERPYAGENT_MODELPYAGENT_BASE_URLPYAGENT_API_KEYPYAGENT_API_KEY_ENV
Profile JSON fields
provider— provider type:ollama,openai_compatible,openai, orvllmbase_url— endpoint base URLmodel— model nameapi_key— inline API key, if neededapi_key_env— environment variable containing the API keyheaders— optional object of extra HTTP headershttpx_kwargs— optional object of keyword arguments passed tohttpx.Clientfor OpenAI-compatible profiles onlyhttp_kwargs— legacy alias forhttpx_kwargs
Extensions
Extensions are Python packages that observe and modify the agent loop through a synchronous event bus: they subscribe to lifecycle events, run deterministic logic (e.g. safeguards), and inject skills into the system prompt for one turn only. Extensions live under ~/.pyagent/extensions/ and are managed with the /extension command.
Each extension is a self-contained package directory that colocates its script, skills, and tools:
~/.pyagent/extensions/<name>/
├── __init__.py # the extension script (register(bus, name))
├── skills/ # *.md skills injected via ctx.add_skill("<key>")
└── tools/ # *.py UV-script tools, discovered only while loaded
A bare ~/.pyagent/extensions/<name>.py file is also accepted (backward compatible), but only a package directory can carry colocated skills/ and tools/.
Extensions are intentionally minimal and one-directional: PyAgent emits events and honors skill text; extensions subscribe and decide what to do. An extension's skills and tools are only discoverable while the extension is loaded — unloading (or not loading) an extension hides its skills and tools again. /extension new <name> scaffolds a starter package with empty skills/ and tools/ dirs.
What an extension can do
- Handle lifecycle events — observe or intercept
input,before_agent_start,turn_start,context,message_start,message_end,tool_call(block or rewrite),tool_result(filter or redact),turn_end,agent_end,model_select,session_start,session_shutdown. - Inject a skill for one turn via
ctx.add_skill(key)— the skill's Markdown (under~/.pyagent/extensions/<name>/skills/<key>.md) is appended to the system prompt for the turn in which it is declared (when declared atturn_start/before_agent_start/input) or next turn (when declared atturn_end), and auto-expunged after. The skill is resolved against the declaring extension'sskills/dir, so it is only reachable while that extension is loaded. Lean by default, ephemeral by design. (The legacy~/.pyagent/skills/extensions/<key>.mdlocation is still read as a fallback for existing setups.) - Run deterministic logic — safeguards, argument rewrites, result redaction. All logic lives in the extension file itself; no network, no state external to the turn.
The event catalog
Events are emitted synchronously from the agent loop; handlers are called in load order. A handler returns a dict to mutate behavior. For veto fields (blocked, cancel), the first handler to set a truthy value short-circuits; for other fields the last writer wins. A handler that raises is isolated — the bus logs the fault and continues, so one broken extension never halts the loop. Handlers are synchronous only; an accidental async def return is closed and ignored.
| Event | Payload | Notable return fields |
|---|---|---|
input |
{text, source} |
action: "continue"|"transform"|"handled", text |
before_agent_start |
{prompt, system_prompt} |
system_prompt (replace; append to event["system_prompt"] to keep the base prompt) |
agent_start |
{} |
— |
turn_start |
{turn_index, timestamp} |
— |
context |
{messages} (deepcopy) |
messages (prune/redact the request only; stored history untouched) |
message_start |
{message: {role: "assistant"}} |
— |
message_end |
{message} |
message (replace the finalized assistant message) |
tool_call |
{tool_call_id, name, input} |
blocked: bool, reason, input (mutate arguments) |
tool_result |
{tool_call_id, name, input, content, details, is_error} |
content, is_error (filter/redact) |
turn_end |
{turn_index, message, message_count, tool_results} |
— |
agent_end |
{messages} |
— |
model_select |
{model, previous_model, source} |
— |
session_start |
{reason} |
— |
session_shutdown |
{reason} |
— |
The context event's messages is a deep copy (only allocated when a handler exists); mutating it affects only the LLM request, never the stored conversation.
Writing an extension
An extension is a package (~/.pyagent/extensions/<name>/__init__.py) exposing a module-level register(bus, name). Subscribe with @bus.on(...) — the scoped bus auto-tags your handlers so /extension unload <name> can remove them. Return a dict to mutate the payload, or None to pass through. Drop skill Markdown into <name>/skills/ and UV-script tools into <name>/tools/; both are discovered only while the extension is loaded.
# ~/.pyagent/extensions/safeguard/__init__.py
def register(bus, name):
@bus.on("tool_call")
def on_tool_call(payload, ctx):
# Veto key: first truthy value short-circuits.
if payload["name"] == "bash" and "rm -rf" in payload["input"].get("command", ""):
return {"blocked": True, "reason": f"{name} blocks destructive commands"}
return None
@bus.on("turn_end")
def on_turn_end(payload, ctx):
# Inject a skill next turn once the conversation grows. The skill text
# is read from ~/.pyagent/extensions/safeguard/skills/guide.md; it is
# injected for one turn only and auto-expunged. Re-declare each turn
# to persist.
if payload.get("message_count", 0) > 20:
ctx.add_skill("guide")
Context (ctx) surface: ctx.extension (this extension's name), ctx.add_skill(key) (declare a skill — this turn if called from input/before_agent_start/turn_start, otherwise next turn), ctx.log (the active SessionLogger, or a no-op when logging is off). That is the entire surface — there is no ctx.agent, ctx.config, ctx.ui, or ctx.bus.
How skills are injected (and removed)
Extension skills live under ~/.pyagent/extensions/<name>/skills/<key>.md and are hidden from normal skill discovery (list_skills never lists them). The flow:
- During an event, a handler calls
ctx.add_skill(key). - The intent is routed based on when it is declared:
- Declared at
input,before_agent_start, orturn_start→ injected into the system prompt this turn (the prompt is recompiled afterturn_startfires, so the skill reaches the LLM in the same turn — this is the natural place to declare an always-on skill like a coder assistant). - Declared at
turn_end(or any later event) → applied next turn (one-turn lag, rotated in at the top of the next turn).
- Declared at
- The agent appends each declared skill's Markdown to the system prompt (total budget
MAX_EXTENSION_SKILLS_CHARS = 15_000, truncated if exceeded; missing/unreadable files are skipped). - At the end of the turn the injection is wiped — to keep the skill every turn, the extension re-declares it each turn (e.g. at every
turn_start).
The system prompt is split into a stable base (file content + tool notes + project context, plus any before_agent_start rewrite) and an extension-skill suffix recomputed from the active skill set. The suffix is re-injected after every turn_start without clobbering the base.
A before_agent_start handler that returns system_prompt replaces the base prompt (extension skills are still appended after); to augment, read payload["system_prompt"] and append.
Managing extensions
/extension list # show extensions in ~/.pyagent/extensions/
/extension reload # re-scan and reload all extensions
/extension new <name> # scaffold a starter extension
/extension load <name> # move (if disabled) into enabled/ and load into the bus this session
/extension unload <name> # remove from the bus this session AND move into disabled/ (stays unloaded across restarts)
/extension enable <name> # move an extension out of disabled/ so it auto-loads next session (does not load into the running bus)
/extension disable <name> # move an extension into disabled/ so it does NOT auto-load next session (unloads it from the running bus if loaded)
/extension remove <name> # permanently delete an extension from disk (checked in both enabled and disabled/)
/extension is also accepted as /extensions.
load/unloadoperate on the in-memory bus (no manifests) and also rebuild the external-tool registry so a loaded extension's colocatedtools/becomes discoverable (and an unloaded extension's tools disappear).unloadpersists: it moves the extension intodisabled/and removes it from the bus, so it will not be re-loaded on the next session until you run/extension load <name>again. Useunloadwhen you want "off now, off next time" in one step.disableisunloadminus nothing — it moves the extension todisabled/and, if it was loaded in the current session, detaches it from the bus. It will not auto-load next session. Usedisablewhen you only care about startup behavior, not the current bus.enableis the inverse ofdisable: it moves the extension back out ofdisabled/so it will auto-load on the next session. It does not load the extension into the current running bus — run/extension load <name>(or/extension reload) afterward to load it now.reloadclears all handlers and re-scans the directory (idempotent: no double-subscribe). A failing extension is logged and skipped at load — it never blocks startup.
The ~/.pyagent/ layout:
~/.pyagent/
├── extensions/ # one package directory per extension
│ └── safeguard/
│ ├── __init__.py # register(bus, name)
│ ├── skills/ # *.md (hidden from list_skills; loaded-gated)
│ │ └── guide.md
│ └── tools/ # *.py UV-script tools (loaded-gated)
│ └── helper.py
├── skills/ # user-global skills (always discoverable)
└── tools/ # global UV-script tools (always discoverable)
Reference examples
Two examples ship under examples/extensions/:
examples/extensions/template/— a copy-paste starter showing an event handler (safeguard) and a one-turn skill injection.examples/extensions/compaction/— a deterministic reference: aturn_endhandler injects thecompactionskill once the conversation grows past a soft threshold. The skill (skills/compaction.md) tells the LLM to use thecompact_contexttool (a deterministic condenser; reference script atexamples/tools/compact_context.py) to compact older context. Copy the extension package to~/.pyagent/extensions/compaction/, drop the tool into itstools/dir, then/extension reload.
Each example has a __main__ self-check (PYTHONPATH=. python examples/extensions/template/__init__.py).
Logging and fault isolation
Extensions log through ctx.log (the active SessionLogger, or a no-op when logging is off); do not call logging.getLogger(...). Bus events and handler faults are logged automatically. A handler that raises is caught per-emit, logged with the event/handler/extension/traceback, and skipped — the payload is unaffected and the loop continues.
Development
Quick smoke test:
python test_agent.py
For non-trivial changes, run:
python -m py_compile pyagent/*.py test_agent.py
python -m unittest -v
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pyagent_harness-0.11.1.tar.gz.
File metadata
- Download URL: pyagent_harness-0.11.1.tar.gz
- Upload date:
- Size: 105.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a2130745797329e5a6a253c3aced05c411507c0072ae1b786fa257e90eaf9fdf
|
|
| MD5 |
769f12f5b1662461e748dcabe7057312
|
|
| BLAKE2b-256 |
8d1cec2957740d7a9933c4b15f5441a7456cb1237b9af34f9b669b790dc1b4c1
|
File details
Details for the file pyagent_harness-0.11.1-py3-none-any.whl.
File metadata
- Download URL: pyagent_harness-0.11.1-py3-none-any.whl
- Upload date:
- Size: 90.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0da7be2e4be274461a0d9ac61944ad83b9cc65f3976d2537ea387617b3b98faa
|
|
| MD5 |
4fb16194cbb6a44b23161cc5d1e1e33e
|
|
| BLAKE2b-256 |
db40add29aa857823e4bcad49719b858011c652a4d31997f5ed4ed3b535b2d9b
|