Skip to main content

Agent Shell

Agent Shell is a light weight abstraction for executing a cli coding agent headlessly and returning the output that can be used programatically as a unified contract

Features

  • One unified contract — the same execute, stream, health_check, and list_models API across every agent; swap the backend without changing consuming code.
  • Seven CLI agents — Claude Code, OpenCode, Copilot CLI, Codex, Pi, Cursor, and Grok behind a common adapter protocol.
  • Execute or stream — get one AgentResponse (raises AgentExecutionError on a failed run), or async-iterate normalized StreamEvents with optional thinking/reasoning.
  • Composable execution policy — preserve native execution by default, or opt into Linux PID namespace isolation without changing an agent adapter.
  • Session resumption — continue any conversation by passing back its session_id.
  • Normalized cost & tokens — consistent cost and output_tokens (reasoning included) regardless of how each CLI reports them.
  • Model discovery — retrieve the exact account/workspace-aware model strings accepted by each CLI, without inference calls, SDK dependencies, or static catalogs.
  • Health checks — confirm an agent + model combination actually works before you rely on it, read from the event stream rather than unreliable exit codes.
  • Portable tool control — one canonical allow/deny vocabulary (bash, edit, read, web_search, web_fetch) translated to each CLI's own tool names.
  • Unified MCP management — register, remove, and list MCP servers across agents through a single API.
  • Async & dependency-free — pure asyncio, zero runtime dependencies, Python 3.12+.

Installation

uv add agent-shell-py

or with pip:

pip install agent-shell-py

Agent skills

Skill_Banner

The repository includes reusable skills that teach coding agents how to use AgentShell:

  • invoking-cli-agents — invoke, stream, resume, and restrict CLI agents.
  • delegating-code-review — delegate an independent code review through AgentShell.

Install them interactively with the Vercel Skills CLI:

npx skills add ScottRBK/agent-shell

Or install both skills globally for every coding agent supported by AgentShell:

npx skills add ScottRBK/agent-shell --global \
  --skill '*' \
  --agent claude-code opencode github-copilot codex pi cursor grok \
  --yes

Install only the core AgentShell skill with:

npx skills add ScottRBK/agent-shell --skill invoking-cli-agents

The skills provide agent instructions. Install agent-shell-py and the chosen coding-agent CLIs separately.

Examples

Execution host and isolation

Existing callers remain unchanged when AGENTSHELL_ISOLATION_POLICY is unset. Omitting both constructor settings then means NativeExecutionHost() plus NoIsolation():

shell = AgentShell(agent_type=AgentType.CLAUDE_CODE)

Set a process-wide deployment default without changing Python call sites:

export AGENTSHELL_ISOLATION_POLICY=linux-pid-namespace

The accepted values are none and linux-pid-namespace. AgentShell reads the value when each shell is constructed. An empty or unknown value raises ValueError rather than silently disabling isolation. An explicit constructor policy takes precedence over the environment, so a caller can still force one shell to use NoIsolation() or a custom policy.

To protect the AgentShell owner from broad same-user cleanup commands such as pkill -f, explicitly request Linux PID namespace isolation:

from agent_shell.execution import LinuxPidNamespaceIsolation

shell = AgentShell(
    agent_type=AgentType.CLAUDE_CODE,
    isolation_policy=LinuxPidNamespaceIsolation(),
)

The policy runs a tiny namespace init as PID 1 and the real CLI as PID 2 or later. Processes in that child namespace cannot signal AgentShell's ancestor processes. With the default private /proc mount they cannot see those ancestors either. The feature requires Linux, the unshare command, and kernel support for unprivileged user/PID namespaces. If any are unavailable, launching raises IsolationUnavailableError; it never silently falls back.

LinuxPidNamespaceIsolation() mounts a private /proc view by default, so tools such as ps show namespace PIDs. In a restricted container where that mount is denied but the PID namespace itself is available, opt into the inherited outer /proc view explicitly:

isolation_policy=LinuxPidNamespaceIsolation(mount_proc=False)

The PID and signal boundary, PID 1 reaper, and descendant cleanup are retained, but /proc and ps can then expose outer PID information that disagrees with the child's os.getpid(). The selected mode is checked during the availability probe and still fails closed when unavailable; it does not create a private mount namespace. The environment setting AGENTSHELL_ISOLATION_POLICY=linux-pid-namespace always selects the default mount_proc=True mode; use the explicit constructor for the opt-in mode.

This is direct-signal protection, not a sandbox. It does not restrict files, credentials, network access, tools, or resource consumption. Any background descendants are also terminated when the isolated namespace ends. execute(), stream(), and health_check() use the selected host/policy; model discovery and MCP configuration remain local management operations.

NativeExecutionHost remains the default. Execution hosts are opt-in and do not add Python dependencies.

[!WARNING] HerdrExecutionHost, TmuxExecutionHost, and TerminalWindowExecutionHost are experimental. They are opt-in, and their constructors, placement options, supported platforms, and lifecycle behaviour may change in a later minor release as real-world usage expands. Explicit requests continue to fail closed when their external prerequisite or requested policy is unavailable; AgentShell never silently substitutes NativeExecutionHost.

Herdr execution host (experimental)

An opt-in HerdrExecutionHost runs each command in a uniquely owned Herdr pane while preserving the same adapter API:

from agent_shell import HerdrExecutionHost

shell = AgentShell(
    agent_type=AgentType.CLAUDE_CODE,
    execution_host=HerdrExecutionHost(),
)

The herdr executable is an optional external prerequisite; AgentShell does not install a Python Herdr client or silently fall back to native execution. Herdr host v1 supports NoIsolation() only, and supports DEVNULL or PIPE stdin. A private, standard-library bridge carries the command, environment, output, status, and cancellation between the host and the Herdr worker, so secrets are not placed in the worker's launcher arguments. RunHandle.pid identifies the bridge worker, while the target CLI's normal or signal return status is preserved. Every run cleans up only its own Herdr pane, workspace, and bridge directory. Cleanup is bounded (five seconds by default; configure cleanup_timeout= when constructing the host). Model discovery and MCP configuration are local management operations and do not create Herdr panes.

On Linux, both the bridge worker and its target use a parent-death guard, so an abrupt Herdr pane or worker exit cannot leave the target running. Other Unix platforms use the normal bridge disconnect cleanup but do not provide this kernel-level guard; an abrupt worker or pane death may therefore require external cleanup.

tmux execution host (experimental)

For a visible tmux run, opt into TmuxExecutionHost (the tmux executable itself is an optional system prerequisite):

from agent_shell import TmuxExecutionHost, TmuxPlacement

shell = AgentShell(
    agent_type=AgentType.CLAUDE_CODE,
    execution_host=TmuxExecutionHost(),
)

By default, each run creates and owns one uniquely named tmux session and pane. Placement can be chosen explicitly: TmuxPlacement.new_session(name=None) creates an owned session, while TmuxPlacement.new_window(session, focus=False) borrows an existing session and owns only the new window. TmuxPlacement.current_session(focus=False) is a convenience for creating a window in the current tmux session and fails clearly when called outside tmux. focus defaults to false, so creating a window does not change the user's active window.

A private, one-shot stdlib bridge keeps stdout and stderr as separate raw streams for the adapter while mirroring output into the pane; the command, working directory, environment, and prompt are sent over the private Unix socket, so secrets are not placed in the tmux command line. RunHandle.pid identifies the bridge/worker that owns the lifecycle, while returncode is the target CLI's exact exit or signal status.

Version 1 supports NoIsolation and DEVNULL or PIPE stdin only. Unsupported isolation policies and arbitrary file descriptors fail closed before a tmux session is created. Completed, cancelled, abandoned, and interpreter-exit runs clean up their AgentShell-owned resource: a new session for session placement, or exactly the new window for borrowed-session placement. The host never kills unrelated tmux sessions or windows. Missing sessions, explicit name collisions, and unidentifiable windows fail closed without falling back to a new session. If tmux is unavailable, TmuxUnavailableError is raised and AgentShell does not silently fall back to the native host. Model discovery and MCP configuration remain local management operations.

Terminal-window execution host (experimental)

To watch a headless run in a new local graphical terminal window, inject the host into AgentShell:

from agent_shell import TerminalWindowExecutionHost
from agent_shell.shell import AgentShell
from agent_shell.models.agent import AgentType

shell = AgentShell(
    agent_type=AgentType.CLAUDE_CODE,
    execution_host=TerminalWindowExecutionHost(),
)

The host is Linux-focused in v1 and needs an installed terminal emulator plus a usable X11 or Wayland session. It discovers x-terminal-emulator, gnome-terminal, konsole, kitty, alacritty, foot, wezterm, or xterm in that order. These are optional external prerequisites; AgentShell has no terminal-emulator or Python runtime dependency. A caller can inject a TerminalWindowLauncher strategy (for example, SubprocessTerminalLauncher) for a different emulator or platform. If no launcher or graphical session is available, the request raises TerminalWindowUnavailableError and does not fall back to native execution. For a simple local override, set AGENTSHELL_TERMINAL_LAUNCHER to an executable name or path; strategies are preferred when custom argument conventions are needed.

Each run opens a one-shot worker. The worker receives the command, environment, and prompt-bearing CLI arguments over a private mode-0700 directory and Unix socket; only the non-secret socket path is passed to the terminal launcher. Raw stdout and stderr bytes are forwarded unchanged to AgentShell and mirrored to the visible terminal. RunHandle.pid is the worker PID, and the target command's exact exit or signal status is returned.

Only NoIsolation and DEVNULL/PIPE stdin are supported in v1. Other isolation policies and arbitrary file descriptors are rejected before launch. Runs are headless and machine-controlled despite being visible. Completed windows and transport resources close by default; there is no hold-open option in v1. Cancellation is sent through the private socket, and abandoning the owner cleans the worker, socket, and temporary directory where interpreter shutdown permits.

Execution host and isolation policy remain separate axes, avoiding one host class per host/policy combination. Optional hosts fail closed when a requested isolation policy cannot be transported.

Execute

from agent_shell.shell import AgentShell
from agent_shell.models.agent import AgentType

shell = AgentShell(agent_type=AgentType.CLAUDE_CODE)

response = await shell.execute(
    cwd="/path/to/project",
    prompt="Can you tell me about this project?",
    allowed_tools=["Read", "Glob", "Grep"],
    model="sonnet",
)

print(response.response)
print(f"Cost: ${response.cost:.4f}")
print(f"Output tokens: {response.output_tokens}")  # billed output, reasoning included
print(f"Session: {response.session_id}")

# Resume the conversation using the session_id
follow_up = await shell.execute(
    cwd="/path/to/project",
    prompt="Now refactor the auth module based on your findings",
    allowed_tools=["Read", "Edit", "Bash"],
    model="sonnet",
    session_id=response.session_id,
)

output_tokens is a cost measure: the billed output-token count, which includes reasoning tokens (they are billed at the output rate). It is reported consistently across all adapters.

Failure handling

execute() raises AgentExecutionError instead of returning when a run failed — an error event was emitted, the terminal result had content == "error", or no terminal result arrived at all. str(e) is the bare reason; the exception also carries whatever partial response/cost/session_id/duration/output_tokens the run produced before failing. When the CLI process itself exits unsuccessfully, returncode is also populated; signal termination uses Python's negative-returncode convention and supplies the positive signal number separately.

from agent_shell.models.agent import AgentExecutionError

try:
    response = await shell.execute(cwd="/path/to/project", prompt="Fix the failing test")
except AgentExecutionError as e:
    print(f"run failed: {e}")   # e.g. "500 model name=qwen3.6-27b-8Q failed to load"
    print(e.returncode, e.signal)  # e.g. -15, 15 for SIGTERM; otherwise None when unavailable

Stream

from agent_shell.shell import AgentShell
from agent_shell.models.agent import AgentType

shell = AgentShell(agent_type=AgentType.CLAUDE_CODE)

async for event in shell.stream(
    cwd="/path/to/project",
    prompt="Refactor the auth module",
    allowed_tools=["Read", "Edit", "Bash"],
    model="sonnet",
    effort="high",
    include_thinking=True,
):
    if event.type == "system":
        print(f"Session: {event.session_id}")
    else:
        print(f"[{event.type}] {event.content}")

Model discovery

Ask the selected CLI which model strings it currently advertises, then pass one back unchanged. Discovery sends no inference prompt and has no model-token cost.

shell = AgentShell(agent_type=AgentType.CLAUDE_CODE)

models = await shell.list_models(cwd="/path/to/project")
selected_model = models[0]

response = await shell.execute(
    cwd="/path/to/project",
    prompt="Review this project",
    model=selected_model,
)

"Available" means advertised as selectable for the current harness, account, and workspace. It does not prove quota, entitlement, credentials, or provider health. The harness's order and aliases such as auto and default are preserved. A genuine empty catalog returns []; discovery failures are raised instead of being mistaken for an empty catalog.

See the agent parameter comparison for each harness's underlying discovery mechanism.

Health check

Verify an agent + model combination actually works before relying on it. It sends a trivial prompt and reports whether a real response came back — catching bad model names, missing credentials, and billing/quota failures. Exit codes alone are unreliable (some CLIs exit 0 on failure), so the verdict is read from the normalized event stream.

shell = AgentShell(agent_type=AgentType.CLAUDE_CODE)

result = await shell.health_check(cwd="/path/to/project", model="haiku")
if not result.healthy:
    print(f"unavailable: {result.exception}")

Restricting tools (disallowed_tools)

Pass a deny-list of tools that the agent must not use. Use the canonical vocabulary {bash, edit, read, web_search, web_fetch} and Agent Shell translates it to each CLI's own tool names — callers don't need to know the per-harness vocabulary:

shell = AgentShell(agent_type=AgentType.CLAUDE_CODE)

response = await shell.execute(
    cwd="/path/to/project",
    prompt="Audit this code but don't run anything or touch the network",
    disallowed_tools=["bash", "web_search", "web_fetch"],
)
  • edit covers write/edit/notebook-edit (it fans out on harnesses that split them).
  • Any name outside the canonical set passes through verbatim (e.g. an MCP tool mcp__server__tool, or a harness-specific name like Write, or Copilot's view).
  • Deny takes precedence over auto-approve on every backend that supports it.
  • Where a backend cannot enforce a deny, the adapter emits a UserWarning listing the ignored tools rather than failing silently. Coverage varies: Claude, OpenCode, and Grok enforce all five canonical names; Copilot enforces only bash/edit canonically (use a verbatim name for its other tools); Codex can only deny web_search; Cursor cannot enforce any per-call deny (its tool policy lives in .cursor/cli.json).
  • Denying edit or read is best-effort: a model can still modify or read files through the shell, so also deny bash when you need a hard file boundary.

OpenCode

from agent_shell.shell import AgentShell
from agent_shell.models.agent import AgentType

shell = AgentShell(agent_type=AgentType.OPENCODE)

response = await shell.execute(
    cwd="/path/to/project",
    prompt="Can you tell me about this project?",
    model="anthropic/claude-sonnet-4-5",
)

print(response.response)
print(f"Session: {response.session_id}")

# Resume the conversation using the session_id
follow_up = await shell.execute(
    cwd="/path/to/project",
    prompt="Now refactor the auth module based on your findings",
    model="anthropic/claude-sonnet-4-5",
    session_id=response.session_id,
)

Note: For OpenCode, allowed_tools and effort are ignored — the adapter maps neither to a CLI flag nor to opencode.json. To restrict an OpenCode agent, use disallowed_tools (see Restricting tools): it is enforced via a per-run OPENCODE_PERMISSION environment variable and holds even under auto-approve. Keep auto_approve=True (the default) — with auto_approve=False, opencode run auto-rejects permission prompts non-interactively and can silently abort the run.

Cursor

from agent_shell.shell import AgentShell
from agent_shell.models.agent import AgentType

shell = AgentShell(agent_type=AgentType.CURSOR)

response = await shell.execute(
    cwd="/path/to/project",
    prompt="Can you tell me about this project?",
)

print(response.response)
print(f"Session: {response.session_id}")

Note: Cursor runs headlessly via cursor-agent --print --output-format stream-json and requires workspace trust, which the adapter always passes (--trust). With auto_approve=True (the default) it also passes --force so tools auto-run; otherwise tools are auto-rejected but the run still completes. allowed_tools, effort, and disallowed_tools are ignored — Cursor exposes no per-call tool policy or effort flag (tool policy lives in .cursor/cli.json), so each emits a UserWarning. On a Free plan only model=None/"auto" works. MCP add/remove/list are supported by directly managing the user-scope ~/.cursor/mcp.json file because cursor-agent mcp has no add/remove subcommands.

Grok

from agent_shell.shell import AgentShell
from agent_shell.models.agent import AgentType

shell = AgentShell(agent_type=AgentType.GROK)

response = await shell.execute(
    cwd="/path/to/project",
    prompt="Can you tell me about this project?",
    model="grok-4.5",
)

print(response.response)
print(f"Session: {response.session_id}")
print(f"Cost: ${response.cost:.4f}")

Note: Grok runs headlessly via grok -p --output-format streaming-messages-json (full assistant blocks — not token-delta streaming-json, which would break newline-joined text collection). With auto_approve=True (the default) the adapter passes --always-approve. effort maps to --reasoning-effort, allowed_tools to --tools, and disallowed_tools to --disallowed-tools (canonical bash maps to run_terminal_cmd — the working deny id, not init.tools' run_terminal_command). The terminal result event carries cost (may be 0 on some auth paths), duration, and raw usage.output_tokens (reasoning is already inside that figure when reported). MCP add/remove/list are supported via grok mcp with user scope only (~/.grok/config.toml).

MCP Servers

Register MCP servers for any supported agent through a unified API. All adapters use user-scope configuration so registrations persist across the agent's execute/stream calls.

from agent_shell.shell import AgentShell
from agent_shell.models.agent import AgentType, MCPServerSpec, MCPServerType

shell = AgentShell(agent_type=AgentType.CLAUDE_CODE)

# Register a stdio MCP server (e.g. forgetful) before running an eval
await shell.add_mcp_server(MCPServerSpec(
    name="forgetful",
    type=MCPServerType.STDIO,
    command="uvx",
    args=["forgetful-ai"],
    env={"FORGETFUL_API_KEY": "..."},
))

response = await shell.execute(
    cwd="/path/to/project",
    prompt="Recall any prior decisions about the auth module",
)

# Optional cleanup
await shell.remove_mcp_server("forgetful")

For HTTP transport, pass url and headers instead of command/args/env:

await shell.add_mcp_server(MCPServerSpec(
    name="remote",
    type=MCPServerType.HTTP,
    url="https://example.com/mcp",
    headers={"Authorization": "Bearer ..."},
))

add_mcp_server adds or updates a server with the same name. Cursor preserves native fields that MCPServerSpec cannot represent when an update keeps the same transport. The configuration is written atomically with user-only permissions. remove_mcp_server warns rather than raises when the named server is not found. list_mcp_servers() works for Claude Code, OpenCode, Copilot CLI, Codex, Cursor, and Grok. Claude Code reads user-scope entries directly from ~/.claude.json, Cursor from ~/.cursor/mcp.json, and Grok from ~/.grok/config.toml, so listing does not launch configured servers for health checks. MCP is not supported for Pi; all three MCP methods raise NotImplementedError.

Logging

Agent Shell uses Python's standard logging module. Configure the agent_shell logger to capture tool calls, session IDs, costs, and errors:

import logging

logging.getLogger("agent_shell").setLevel(logging.INFO)
logging.getLogger("agent_shell").addHandler(logging.StreamHandler())

Set to DEBUG for raw JSON events and full command arguments.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

agent_shell_py-0.3.2.tar.gz (1.9 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

agent_shell_py-0.3.2-py3-none-any.whl (96.4 kB view details)

Uploaded Python 3

File details

Details for the file agent_shell_py-0.3.2.tar.gz.

File metadata

  • Download URL: agent_shell_py-0.3.2.tar.gz
  • Upload date:
  • Size: 1.9 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for agent_shell_py-0.3.2.tar.gz
Algorithm Hash digest
SHA256 db938dc658022b820bb43e9db3faf0746ebd6450cee7a78f0970ea91c2a69ac9
MD5 d2793541cda94e1b2f57a18981c9db94
BLAKE2b-256 0528089ed289750d7ea4120b9f1dd809a96e3984cb669c863459e9d6f0bdf8c5

See more details on using hashes here.

Provenance

The following attestation bundles were made for agent_shell_py-0.3.2.tar.gz:

Publisher: publish.yml on ScottRBK/agent-shell

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file agent_shell_py-0.3.2-py3-none-any.whl.

File metadata

  • Download URL: agent_shell_py-0.3.2-py3-none-any.whl
  • Upload date:
  • Size: 96.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for agent_shell_py-0.3.2-py3-none-any.whl
Algorithm Hash digest
SHA256 594b0bbfb4efbb435354a9d6fa0c7d20c5c20b4b9e35945ef5a83b2c6d892ccc
MD5 4734aecc5c12b1f87b1556970ea0d21e
BLAKE2b-256 dd285a628e4c2bcfd655ad21809bbdb3151f0ab183a8557238e1d8ef215e2d73

See more details on using hashes here.

Provenance

The following attestation bundles were made for agent_shell_py-0.3.2-py3-none-any.whl:

Publisher: publish.yml on ScottRBK/agent-shell

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.4.0

2 files

This release

0.3.2 This release

2 files

0.3.1

2 files

0.3.0

2 files

0.2.5

2 files

0.2.4

2 files

0.2.3

2 files

0.2.2

2 files

0.2.1

2 files

0.2.0

2 files

0.1.17

2 files

0.1.16

2 files

0.1.15

2 files

0.1.14

2 files

0.1.13

2 files

0.1.12

2 files

0.1.11

2 files

0.1.10

2 files

0.1.9

2 files

0.1.8

2 files

0.1.7

2 files

0.1.5

2 files

0.1.4

2 files

0.1.3

2 files

0.1.0

2 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