Skip to main content

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 native Ollama chat endpoints plus both the OpenAI-compatible Chat Completions and Responses APIs.

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",
      "api_mode": "responses",
      "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.
  • Stores versioned agent definitions that compose profiles, prompts, skills, tool allowlists, workspaces, and run limits.
  • Supports Ollama natively and OpenAI-compatible providers through the OpenAI Python SDK.
  • Loads layered always-on instructions from user-global and project-local AGENTS.md files, with .md / .skill skills 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",
      "api_mode": "responses",
      "base_url": "https://api.openai.com/v1",
      "model": "gpt-4.1",
      "api_key_env": "OPENAI_API_KEY"
    },
    "vllm-local": {
      "provider": "vllm",
      "api_mode": "chat_completions",
      "base_url": "http://localhost:8000/v1",
      "model": "Qwen/Qwen2.5-Coder-32B-Instruct",
      "api_key_env": "VLLM_API_KEY"
    }
  }
}

Supported provider values:

  • ollama
  • openai_compatible
  • openai
  • vllm

openai and vllm are treated as OpenAI-compatible providers.

OpenAI API modes

OpenAI-compatible profiles accept an api_mode field:

  • chat_completions — use /v1/chat/completions; this is the default when the field is omitted
  • responses — use /v1/responses

Both modes support streaming text and PyAgent function tools. The Responses adapter translates PyAgent's chat-shaped history and tool schemas into Responses input items, and preserves raw response output during immediate tool continuations so reasoning items are not discarded. Conversation history remains locally controlled rather than relying on previous_response_id. Every Responses request sets store=false; this is always enforced and is not profile-configurable.

Use responses only with an endpoint that implements the Responses API. OpenAI supports it, while support in vLLM and other OpenAI-compatible servers depends on the server and version. There is intentionally no automatic fallback to Chat Completions when a Responses request fails.

API keys

Profiles can specify either:

  • api_key — inline secret value
  • api_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 requests
  • httpx_kwargs — keyword arguments passed to httpx.Client for OpenAI-compatible providers only
  • http_kwargs — legacy alias for httpx_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
      }
    }
  }
}

Managing profiles from the CLI

You can now manage your model profiles directly from the CLI without editing the JSON file:

pyagent profiles list
pyagent profiles create --name my-model --provider ollama --model gemma4:latest
pyagent profiles create --name openai-responses --provider openai --model gpt-4.1-mini --api-mode responses --api-key-env OPENAI_API_KEY

Options for profiles create:

  • --base-url / --base_url — endpoint base URL; defaults to the provider's standard URL
  • --api-mode / --api_mode — chat_completions (default) or responses
  • --api-key-env / --api_key_env — environment variable containing the API key

Fallback profile from environment

If the profile file does not exist, PyAgent creates an implicit default profile from environment variables:

  • PYAGENT_PROFILE
  • PYAGENT_PROVIDER
  • PYAGENT_MODEL
  • PYAGENT_BASE_URL
  • PYAGENT_API_MODE (chat_completions or responses)
  • PYAGENT_API_KEY
  • PYAGENT_API_KEY_ENV

Reusable agent definitions

Agent definitions are versioned JSON resources under:

~/.pyagent/agents/

They compose existing PyAgent resources into a reusable role. A definition may select a model profile and override, an installed prompt, explicit skills, a tool allowlist, a workspace, an iteration limit, labels, and capability metadata.

Create and inspect a definition:

pyagent agents create reviewer \
  --description "Reviews Python changes" \
  --profile openai-mini \
  --prompt-resource reviewer.md \
  --skill code-review.md \
  --tool read_file \
  --tool find_files \
  --tool search_text \
  --workspace /work/project-a \
  --max-iterations 5 \
  --label team=engineering \
  --capability code-review

pyagent agents list
pyagent agents show reviewer
pyagent agents validate reviewer

Updates create immutable numbered revisions. The current revision is stored as agents/<name>.json; historical snapshots are stored under agents/.revisions/<name>/.

pyagent agents update reviewer --model gpt-4.1 --max-iterations 7
pyagent agents revisions reviewer
pyagent agents show reviewer --revision 1

Agent-definition CLI update semantics

pyagent agents update is a partial update that always creates a new revision:

  • Omitted fields keep their values from the current revision.
  • Scalar options such as --profile, --model, --prompt-resource, --workspace, and --max-iterations replace only that field.
  • Repeated --skill values replace the complete skill list; they do not append to the existing list.
  • Repeated --tool values replace the complete tool allowlist; they do not append to it.
  • Repeated --capability values and --label KEY=VALUE values replace their complete collections when supplied.
  • --no-tools stores an empty allowlist and disables model tool calling for that agent. Do not combine it with --tool; --no-tools takes precedence.
  • The agent name and existing revision metadata are immutable.

Changing a profile does not clear an existing model override. Supply both values when moving an overridden agent to a different profile:

pyagent agents update reviewer \
  --profile hf-inference-providers \
  --model google/gemma-4-31B-it:fastest

The CLI currently has no --clear-model, --clear-profile, --clear-prompt, --clear-workspace, or --inherit-max-iterations option. Use the REST API or Python client to set those optional fields to JSON null. Use empty arrays or objects to clear skills, tools, capabilities, or labels.

Create and update perform structural validation before writing. Run pyagent agents validate <name> afterward to resolve profiles, credentials, prompts, skills, external tools, and the workspace against the current environment.

Run one prompt using the current or a pinned revision:

pyagent agents run reviewer "Review the current diff"
pyagent agents run reviewer "Review the current diff" --revision 1 --cwd /work

The pyagent serve browser UI also has an Agents page for creating, editing, validating, deleting, and inspecting revisions. Choose Run in chat to open a browser-local conversation pinned to the selected revision, or select any current agent definition from the chat runtime menu.

Definition fields:

  • profile — saved model profile; omitted means the configured default.
  • model — optional model override for that profile.
  • prompt — installed prompt name under ~/.pyagent/system_prompts/; omitted means the active system prompt.
  • skills — user or project skill references loaded into context.
  • tools — explicit tool allowlist. null inherits all enabled tools; an empty list disables tool calling.
  • workspace — absolute path, or a path relative to the run's cwd.
  • max_iterations — positive integer or -1 for unlimited iterations.
  • labels / capabilities — orchestration metadata reserved for routing and discovery.

Validation resolves the profile, prompt, skills, tools, workspace, and effective iteration limit without calling the model. Definitions run with workspace-bound tools: relative built-in paths, bash startup, and external-tool subprocesses use the resolved workspace. File tools reject paths that resolve outside it. Bash safety policy still applies, but bash is not a sandbox; omit bash from an untrusted agent's allowlist.

Enabled extensions continue to auto-load using the existing global extension lifecycle; per-definition extension selection is not part of this first slice. Extension-provided tools may still be included in a definition's tool allowlist.

This is the local orchestration foundation, not yet a durable deployment scheduler: /agents/{name}/run constructs the pinned agent revision for one request. Persistent deployments, sessions, queues, and coordinator delegation can build on these definitions without changing their schema contract.

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

Open http://127.0.0.1:8000/ui/ (or the server root) for the bundled browser UI. The first release includes:

  • streaming chat with visible tool calls/results and a Stop control
  • per-conversation profile, model, working-directory, skill, and debug controls
  • browser-local conversation history stored in IndexedDB, with a local-storage fallback
  • profile creation, editing, deletion, default selection, and endpoint model discovery
  • a resource manager for uploading, installing, enabling/disabling, and deleting tools, skills, and extensions
  • a versioned agent editor with resource selection, validation, revision history, and Run in chat
  • ad hoc and stored-agent conversations using the same streaming transcript and Stop control
  • secret-preserving profile updates: blank credentials preserve existing values, while explicit clear controls send null

The compiled UI ships inside the Python wheel; Node is not needed to run it. Frontend contributors can run a development server separately:

# terminal 1
pyagent serve

# terminal 2
cd web
npm install
npm run dev

Vite proxies API requests to http://127.0.0.1:8000. Production assets are generated with npm run build into pyagent/webui/dist/ and must be rebuilt when frontend sources change.

Security: pyagent serve and its browser UI are intended for trusted localhost use. They do not currently provide authentication, workspace-root confinement for arbitrary API runs, or a security boundary around every executable tool/extension. Do not expose the server to an untrusted network or users.

Optional bind overrides:

pyagent serve --host 0.0.0.0 --port 8000

Endpoints:

  • GET / / GET /ui/ — redirect to or serve the bundled browser UI; /ui/assets/* serves compiled static assets
  • GET /health — basic health check
  • GET /version — installed PyAgent package version
  • GET /profiles / POST /profiles — list or create model profiles
  • GET /profiles/{name} / PUT /profiles/{name} / DELETE /profiles/{name} — inspect, partially update, or remove a profile
  • POST /profiles/{name}/default — make a profile the stored default
  • GET /profiles/{name}/models — list models reported by a profile's endpoint
  • POST /run — run a single non-streaming agent turn
  • POST /run/stream — stream an agent turn as Server-Sent Events (SSE)
  • GET /agents / POST /agents — list or create versioned agent definitions
  • GET /agents/{name}?revision=<n> / GET /agents/{name}/revisions — read or list definition revisions
  • PUT /agents/{name} / DELETE /agents/{name} — create a new revision or remove a definition
  • POST /agents/{name}/validate?revision=<n>&cwd=<path> — resolve and validate a definition
  • POST /agents/{name}/run — run one non-streaming turn using a current or pinned definition revision
  • POST /agents/{name}/run/stream — stream a turn using a current or pinned definition revision
  • GET /prompts — list installed reusable system prompts
  • POST /prompts/install — install a prompt from a multipart file upload (file) or JSON HTTP(S) URL ({"url": "..."})
  • GET /prompts/{name} — read an installed prompt
  • POST /prompts/{name}/use — copy an installed prompt to the active system prompt path
  • DELETE /prompts/{name} — remove an installed prompt
  • GET /skills?cwd=/path/to/project — list user and project skills discoverable from a cwd
  • POST /skills/install — install a user skill from a multipart file upload (file) or JSON HTTP(S) URL
  • DELETE /skills/{name} — remove a user-managed skill
  • GET /tools — list tool configuration, built-ins, installed tool files, discovered external tools, broken tools, disabled tools, and name collisions
  • POST /tools/install — install a user tool from a multipart file upload (file) or JSON HTTP(S) URL
  • POST /tools/new — scaffold a starter user tool ({"name": "my_tool"})
  • GET /tools/{name}/path — locate an enabled or disabled user tool script
  • POST /tools/{name}/enable / POST /tools/{name}/disable — move a user tool in or out of tools/disabled/
  • DELETE /tools/{name} — remove a user-managed tool
  • GET /extensions — list enabled and disabled extensions on disk
  • POST /extensions/install — upload a single .py extension or zipped package, install a direct .py/.zip URL, or clone an HTTP(S) Git repository URL
  • POST /extensions/new — scaffold an extension ({"name": "demo"}) or install one from a GitHub URL ({"name": "demo", "url": "..."})
  • POST /extensions/{name}/enable / POST /extensions/{name}/disable — move an extension in or out of extensions/disabled/
  • DELETE /extensions/{name} — remove an extension from disk

Install endpoints intentionally accept uploaded files or HTTP(S) URLs, not arbitrary server-local source paths. Extension uploads accept a single .py file or a .zip containing one package root with __init__.py; archive paths and expansion size are checked before installation. Git repository installs require an explicit extension name. GET /tools performs the same external-tool discovery used by PyAgent, so user tool scripts may be run with uv run <script> describe.

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",
  "api_mode": "chat_completions",
  "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. If an agent turn yields an internal error event, POST /run returns an HTTP error instead of an empty successful response.

Streaming API

POST /run/stream accepts the same JSON body as POST /run and returns text/event-stream. Use curl -N to prevent output buffering:

curl -N -X POST http://127.0.0.1:8000/run/stream \
  -H 'Accept: text/event-stream' \
  -H 'Content-Type: application/json' \
  -d '{
    "message": "Summarize README.md",
    "profile": "local-qwen",
    "cwd": "."
  }'

Stored agent definitions use the same event contract:

curl -N -X POST http://127.0.0.1:8000/agents/reviewer/run/stream \
  -H 'Accept: text/event-stream' \
  -H 'Content-Type: application/json' \
  -d '{"message": "Review the current changes", "revision": 1}'

Each JSON event includes schema_version, run_id, a monotonically increasing sequence, timestamp, and type. Event types are:

  • start — selected profile/provider/API mode/model and loaded context; stored-agent streams also include agent and revision
  • assistant_start — assistant processing has started
  • content_delta — incremental response text in delta
  • tool_call — tool name, arguments, and tool_call_id
  • tool_result — result, tool_call_id, and is_error
  • debug — internal agent diagnostics, only with ?include_debug=true
  • done — successful terminal event containing the final response, updated messages, and run metadata
  • error — unsuccessful terminal event containing code and message

Idle connections receive SSE heartbeat comments. Request/configuration failures discovered before streaming starts retain normal HTTP error statuses. After SSE headers are sent, model and agent failures are represented by a terminal error event while the HTTP status remains 200. Exactly one done or error event terminates a normally connected stream.

Closing the HTTP connection signals cancellation and closes the agent after the current blocking model or tool operation yields. There is not yet a persistent run registry or a separate cancellation endpoint.

Profile API examples

List the configured profiles:

curl http://127.0.0.1:8000/profiles

The response includes the profile-file path, the stored default, and the effective default. PYAGENT_PROFILE, when set, takes precedence over the stored default and is reported through effective_default_profile and default_overridden_by_env.

Create a profile:

curl -X POST http://127.0.0.1:8000/profiles \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "openai-mini",
    "provider": "openai",
    "api_mode": "responses",
    "model": "gpt-4.1-mini",
    "api_key_env": "OPENAI_API_KEY",
    "make_default": false
  }'

name, provider, and model are required. base_url defaults from the provider and api_mode defaults to chat_completions. Creation returns 409 if the name already exists. Profiles may contain either api_key or api_key_env, but environment-variable references are recommended.

Updates have partial-update semantics: omitted fields are preserved, api_key: null and api_key_env: null clear those credential settings, and empty headers or httpx_kwargs objects clear those complete mappings.

curl -X PUT http://127.0.0.1:8000/profiles/openai-mini \
  -H 'Content-Type: application/json' \
  -d '{"model": "gpt-4.1", "api_key_env": "OPENAI_API_KEY"}'

curl -X POST http://127.0.0.1:8000/profiles/openai-mini/default
curl http://127.0.0.1:8000/profiles/openai-mini/models

Profile responses never return inline API keys. They expose has_inline_api_key instead, omit sensitive header values, list their names in redacted_headers, and redact sensitive nested httpx_kwargs values. Supplying headers or httpx_kwargs during an update replaces that entire mapping, so omit those fields to preserve redacted settings.

The only remaining profile cannot be removed. The stored default also cannot be removed until another profile is selected as default; both conflicts return 409. Setting the stored default does not override PYAGENT_PROFILE in the server environment.

Agent-definition API examples

Create a structurally valid definition:

curl -X POST http://127.0.0.1:8000/agents \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "reviewer",
    "description": "Reviews Python changes",
    "profile": "hf-inference-providers",
    "model": null,
    "prompt": "reviewer.md",
    "skills": ["user:code-review.md"],
    "tools": ["read_file", "find_files", "search_text"],
    "workspace": "/work/project-a",
    "max_iterations": 5,
    "labels": {"team": "engineering"},
    "capabilities": ["code-review"]
  }'

Creation validates the definition's structure but does not require every referenced resource to be available. Resolve it against the server's current profiles, credentials, prompts, skills, tools (including enabled extension tool directories), and filesystem before deployment or execution:

curl -X POST \
  'http://127.0.0.1:8000/agents/reviewer/validate?revision=1&cwd=/work'

Validation returns valid, errors, warnings, and a resolved object containing the effective profile/model, prompt path, skill IDs, tool names, workspace, and iteration limit. It does not call the model.

Update only selected fields and create a new immutable revision:

curl -X PUT http://127.0.0.1:8000/agents/reviewer \
  -H 'Content-Type: application/json' \
  -d '{
    "model": "google/gemma-4-31B-it:fastest",
    "tools": ["read_file", "search_text"]
  }'

PUT /agents/{name} has partial-update semantics: omitted fields are preserved, supplied arrays and objects replace their entire values, and JSON null restores inheritance for optional fields such as profile, model, prompt, workspace, and max_iterations. An empty tools array disables tool calling; tools: null inherits all tools enabled by server configuration. The name and stored revision metadata cannot be updated.

Run the current or a pinned revision. Conversation history remains caller-managed unless a future persistent-session layer is used:

curl -X POST http://127.0.0.1:8000/agents/reviewer/run \
  -H 'Content-Type: application/json' \
  -d '{
    "message": "Review the current changes",
    "messages": [
      {"role": "user", "content": "Focus on correctness."},
      {"role": "assistant", "content": "Understood."}
    ],
    "revision": 1,
    "cwd": "/work"
  }'

Example response:

{
  "agent": "reviewer",
  "revision": 1,
  "response": "...",
  "profile": "hf-inference-providers",
  "provider": "openai_compatible",
  "api_mode": "chat_completions",
  "model": "google/gemma-4-31B-it:fastest",
  "messages": [
    {"role": "system", "content": "..."},
    {"role": "user", "content": "Focus on correctness."},
    {"role": "assistant", "content": "Understood."},
    {"role": "user", "content": "Review the current changes"},
    {"role": "assistant", "content": "..."}
  ],
  "context_files": ["~/.pyagent/AGENTS.md", "AGENTS.md"]
}

revision belongs in the run request body; validation and definition lookup accept it as a query parameter. Incoming system history is ignored. Deleting an agent definition also deletes its local revision history.

Agent-definition endpoints return 404 for missing definitions or revisions, 409 when creation conflicts with an existing name, and 400 for invalid create/update/run configuration or message history. Request-schema errors return FastAPI's 422. A validation result with unresolved resources is a successful 200 response with valid: false; model-stream failures during /agents/{name}/run return 502, while a run that ends without a final assistant response returns 500.

Resource install examples:

# Upload a local prompt file
curl -X POST http://127.0.0.1:8000/prompts/install \
  -F file=@./coder.md

# Install a skill from a URL
curl -X POST http://127.0.0.1:8000/skills/install \
  -H 'Content-Type: application/json' \
  -d '{"url": "https://example.com/review.md"}'

# Scaffold a user tool
curl -X POST http://127.0.0.1:8000/tools/new \
  -H 'Content-Type: application/json' \
  -d '{"name": "my_tool"}'

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

profiles = client.list_profiles()
print(profiles["default_profile"])

client.create_profile({
    "name": "openai-mini",
    "provider": "openai",
    "api_mode": "responses",
    "model": "gpt-4.1-mini",
    "api_key_env": "OPENAI_API_KEY",
})
client.update_profile("openai-mini", {"model": "gpt-4.1"})
print(client.list_profile_models("openai-mini"))

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.api_mode, result.model)
print(result.context_files)

for event in client.stream_run(
    "Summarize README.md",
    profile="local-qwen",
):
    if event["type"] == "content_delta":
        print(event["delta"], end="", flush=True)
    elif event["type"] == "error":
        raise RuntimeError(event["message"])

stream_run() and stream_agent() return iterators of decoded SSE event dictionaries. HTTP and transport failures raise PyAgentClientError; terminal error events from an established stream are yielded so callers can display or handle them. Closing the iterator early closes the HTTP response.

Manage and run reusable agent definitions with the same client:

from pyagent.client import AgentRunResponse, PyAgentClient

client = PyAgentClient("http://127.0.0.1:8000")

created = client.create_agent({
    "name": "researcher",
    "description": "Researches current information",
    "profile": "hf-inference-providers",
    "tools": ["searxng"],
    "workspace": ".",
    "max_iterations": 5,
    "labels": {"team": "research"},
    "capabilities": ["web-search"],
})
print(created["name"], created["revision"])

validation = client.validate_agent("researcher", cwd="/work/project-a")
if not validation["valid"]:
    raise RuntimeError(validation["errors"])

# Changes are partial and create a new revision. None is encoded as JSON null,
# so this clears an existing model override and inherits the profile's model.
updated = client.update_agent("researcher", {"model": None, "max_iterations": 7})
print(updated["revision"])

result: AgentRunResponse = client.run_agent(
    "researcher",
    "Find the official Textual documentation URL.",
    messages=[],
    revision=updated["revision"],
    cwd="/work/project-a",
)
print(result.agent, result.revision, result.response)

Agent-definition management methods return decoded dictionaries. run_agent() returns AgentRunResponse, which includes all RunResponse fields plus agent and revision. Pass RunResponse.messages or AgentRunResponse.messages back through messages to continue caller-managed history. update_agent() sends the supplied change dictionary unchanged: omitted keys are preserved by the server, None clears optional scalar overrides, and arrays/objects replace their complete stored values.

Client details:

  • PyAgentClient.health() returns the decoded /health JSON payload.
  • PyAgentClient.is_healthy() returns True or False without raising on connection failures.
  • PyAgentClient.version() returns the decoded /version JSON payload.
  • PyAgentClient.run(...) returns a typed RunResponse object; stream_run(...) yields decoded SSE events.
  • Profile helpers include list_profiles(), show_profile(), create_profile(), update_profile(), set_default_profile(), remove_profile(), and list_profile_models().
  • Agent-definition helpers include list_agents(), create_agent(), show_agent(), list_agent_revisions(), update_agent(), validate_agent(), remove_agent(), run_agent(), and stream_agent(); run_agent() returns a typed AgentRunResponse.
  • Management helpers are available for prompts, skills, tools, and extensions, including list_prompts(), install_prompt(url=... | file_path=...), show_prompt(), use_prompt(), remove_prompt(), list_skills(), install_skill(...), remove_skill(), list_tools(), install_tool(...), new_tool(), tool_path(), enable_tool(), disable_tool(), remove_tool(), list_extensions(), install_extension(url=... | file_path=...), new_extension(), enable_extension(), disable_extension(), and remove_extension().
  • PyAgentClientError is raised for HTTP errors, invalid JSON responses, connection failures, upload-file read 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
  • *.skill
  • skills/**/*.md
  • skills/**/*.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:

  • /prompt shows 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 /clear to 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:

  1. Built-in tools shipped with the package.
  2. External user tools under ~/.pyagent/tools/.

Built-in tools include:

  • bash
  • list_files
  • find_files
  • search_text
  • read_file
  • write_file
  • append_file
  • edit_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 with name, description, parameters, and optional version.
  • <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 reload rescans 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> or pyagent tools disable <tool_file> moves it to ~/.pyagent/tools/disabled/.
  • Re-enable a tool: /tools enable <name> or pyagent 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. /tools reports colliding external scripts so you can rename them. If PYAGENT_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 unless PYAGENT_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 .txt or .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 .md or .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 reload in an active TUI to apply it there.
  • Use --force with install to 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/)
pyagent extensions new <name> [url]       # scaffold a new extension or install from a GitHub URL (supports subdirectories)

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. Use enable to restore it.
  • After running enable/disable from 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.
  • enable only un-disables an extension that was previously disabled. If an extension is already enabled, enable reports that it was not found in the disabled directory.
  • remove deletes 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
├── agents/                          # versioned reusable agent definitions
│   └── .revisions/                  # immutable historical definition revisions
├── 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> [api_mode=chat_completions|responses] [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; -1 means 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/project AGENTS.md files 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 into disabled/ so it 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/)
  • /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_mode=responses api_key_env=OPENAI_API_KEY default=true
/profile add vllm-qwen provider=vllm model="Qwen/Qwen2.5-Coder-32B-Instruct" api_mode=chat_completions base_url=http://localhost:8000/v1 api_key_env=VLLM_API_KEY header.X-Project=PyAgent

Keyboard shortcuts

  • Enter — send the current prompt
  • Shift+Enter — insert a newline in the prompt box
  • Ctrl+P / Ctrl+N — move through prompt history
  • ↑ / ↓ — scroll the chat transcript
  • PgUp / PgDn — page through the chat transcript
  • Home / End — jump to the top or bottom of the chat transcript
  • Ctrl+L — clear the conversation
  • Ctrl+D — toggle the debug pane
  • Ctrl+C — quit the app

Configuration reference

Core environment variables

  • PYAGENT_PROFILE — default profile name to select
  • PYAGENT_MODEL_PROFILES_PATH — path to the JSON profile file, overriding ~/.pyagent/profiles.json
  • PYAGENT_SYSTEM_PROMPT_PATH — path to the system prompt text file, overriding ~/.pyagent/system_prompt.txt
  • PYAGENT_REQUEST_TIMEOUT — request timeout in seconds
  • PYAGENT_MAX_ITERATIONS — maximum tool loop iterations per user turn; -1 means infinite
  • PYAGENT_MAX_HISTORY_MESSAGES — number of recent non-system messages to keep
  • PYAGENT_MAX_PREVIOUS_TOOL_RESULT_CHARS — maximum characters to send for older tool-result messages; the current trailing tool-result block is kept intact, and 0 or -1 disables masking
  • PYAGENT_STREAM_BATCH_INTERVAL — UI flush interval in seconds
  • PYAGENT_USER_DIR — root for reusable prompts, versioned agent definitions and revision history, tools, skills, extensions, logs, and user-global AGENTS.md; default ~/.pyagent. It does not relocate model profiles or the active system prompt: use PYAGENT_MODEL_PROFILES_PATH and PYAGENT_SYSTEM_PROMPT_PATH for those files.

Tool environment variables

  • PYAGENT_TOOLS_ENABLED — enable or disable all model tool calling for the session; default true
  • PYAGENT_BUILTIN_TOOLS_ENABLED — register built-in tools (bash, file tools, search/edit tools); default true
  • PYAGENT_BASH_ENABLED — enable or disable bash execution; default true (when false, the bash tool remains registered but returns a disabled-by-configuration error)
  • PYAGENT_BASH_READONLY_MODE — restrict bash to read-only command prefixes
  • PYAGENT_BASH_TIMEOUT_DEFAULT — default bash timeout in seconds
  • PYAGENT_BASH_BLOCKED_SUBSTRINGS — comma-separated dangerous bash fragments to block
  • PYAGENT_BASH_READONLY_PREFIXES — comma-separated allowed prefixes in read-only mode
  • PYAGENT_USER_TOOLS_ENABLED — discover and register external tools under ~/.pyagent/tools/; default true
  • PYAGENT_USER_TOOL_TIMEOUT — wall-clock timeout in seconds for each external tool invocation; default 60
  • PYAGENT_USER_TOOL_DESCRIBE_TIMEOUT — wall-clock timeout for the describe schema fetch; default 10
  • PYAGENT_TOOL_RUNNER — executable used to run external tools; default uv

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_PROVIDER
  • PYAGENT_MODEL
  • PYAGENT_BASE_URL
  • PYAGENT_API_MODE — chat_completions (default) or responses
  • PYAGENT_API_KEY
  • PYAGENT_API_KEY_ENV

Profile JSON fields

  • provider — provider type: ollama, openai_compatible, openai, or vllm
  • base_url — endpoint base URL
  • model — model name
  • api_mode — OpenAI API mode: chat_completions (default) or responses; only chat_completions is valid for native Ollama profiles
  • api_key — inline API key, if needed
  • api_key_env — environment variable containing the API key
  • headers — optional object of extra HTTP headers
  • httpx_kwargs — optional object of keyword arguments passed to httpx.Client for OpenAI-compatible profiles only
  • http_kwargs — legacy alias for httpx_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 at turn_start/before_agent_start/input) or next turn (when declared at turn_end), and auto-expunged after. The skill is resolved against the declaring extension's skills/ dir, so it is only reachable while that extension is loaded. Lean by default, ephemeral by design. (The legacy ~/.pyagent/skills/extensions/<key>.md location 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:

  1. During an event, a handler calls ctx.add_skill(key).
  2. The intent is routed based on when it is declared:
    • Declared at input, before_agent_start, or turn_start → injected into the system prompt this turn (the prompt is recompiled after turn_start fires, 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).
  3. 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).
  4. 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/unload operate on the in-memory bus (no manifests) and also rebuild the external-tool registry so a loaded extension's colocated tools/ becomes discoverable (and an unloaded extension's tools disappear).
  • unload persists: it moves the extension into disabled/ and removes it from the bus, so it will not be re-loaded on the next session until you run /extension load <name> again. Use unload when you want "off now, off next time" in one step.
  • disable is unload minus nothing — it moves the extension to disabled/ and, if it was loaded in the current session, detaches it from the bus. It will not auto-load next session. Use disable when you only care about startup behavior, not the current bus.
  • enable is the inverse of disable: it moves the extension back out of disabled/ 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.
  • reload clears 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: a turn_end handler injects the compaction skill once the conversation grows past a soft threshold. The skill (skills/compaction.md) tells the LLM to use the compact_context tool (a deterministic condenser; reference script at examples/tools/compact_context.py) to compact older context. Copy the extension package to ~/.pyagent/extensions/compaction/, drop the tool into its tools/ 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

Metadata

Release files for pyagent-harness 0.15.0

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

Source distribution (sdist)

Source distribution for pyagent-harness 0.15.0
File Size Uploaded
pyagent_harness-0.15.0.tar.gz 212.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pyagent-harness 0.15.0
File Interpreter ABI Platform
pyagent_harness-0.15.0-py3-none-any.whl Python 3 none any Details

Total release size: 398.1 kB

Release files / pyagent_harness-0.15.0.tar.gz

Download URL pyagent_harness-0.15.0.tar.gz
Size 212.6 kB
Tags Source
SHA-256 checksum
How to use checksums
1121439906de708098c0c0eb4b1b06b755b4b3117b00e30cc19ea2fe72f79cef
BLAKE2b-256 checksum
How to use checksums
e22f93b4c0112fef0164c0d7946df2d4973bc4caabccdb1219dfff369dd1ccbe
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.12.12

Release files / pyagent_harness-0.15.0-py3-none-any.whl

Download URL pyagent_harness-0.15.0-py3-none-any.whl
Size 185.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
996bb9654300f0f09fd878e62eb73cb65bc60d82c47421694bb995c0b4606061
BLAKE2b-256 checksum
How to use checksums
5696dff0dc3dd6a471f089bd1e7b95bb9cf2f115abdd54cbbea323d6162f9213
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.12.12

Release history Release notifications | RSS feed

0.15.2

2 release files

This release

0.15.0 This release

2 release files

0.12.0

2 release files

0.11.1

2 release files

0.11.0

2 release files

0.10.0

2 release files

0.9.0

2 release files

0.8.1

2 release files

0.8.0

2 release files

0.7.1

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.3

2 release files

0.5.2

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.1

2 release files

0.1.0

2 release files

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