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, andlist_modelsAPI 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(raisesAgentExecutionErroron a failed run), or async-iterate normalizedStreamEvents 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
costandoutput_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
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, andTerminalWindowExecutionHostare 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 substitutesNativeExecutionHost.
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_tokensis 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"],
)
editcovers 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 likeWrite, or Copilot'sview). - Deny takes precedence over auto-approve on every backend that supports it.
- Where a backend cannot enforce a deny, the adapter emits a
UserWarninglisting the ignored tools rather than failing silently. Coverage varies: Claude, OpenCode, and Grok enforce all five canonical names; Copilot enforces onlybash/editcanonically (use a verbatim name for its other tools); Codex can only denyweb_search; Cursor cannot enforce any per-call deny (its tool policy lives in.cursor/cli.json). - Denying
editorreadis best-effort: a model can still modify or read files through the shell, so also denybashwhen 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_toolsandeffortare ignored — the adapter maps neither to a CLI flag nor toopencode.json. To restrict an OpenCode agent, usedisallowed_tools(see Restricting tools): it is enforced via a per-runOPENCODE_PERMISSIONenvironment variable and holds even under auto-approve. Keepauto_approve=True(the default) — withauto_approve=False,opencode runauto-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-jsonand requires workspace trust, which the adapter always passes (--trust). Withauto_approve=True(the default) it also passes--forceso tools auto-run; otherwise tools are auto-rejected but the run still completes.allowed_tools,effort, anddisallowed_toolsare ignored — Cursor exposes no per-call tool policy or effort flag (tool policy lives in.cursor/cli.json), so each emits aUserWarning. On a Free plan onlymodel=None/"auto"works. MCP add/remove/list are supported by directly managing the user-scope~/.cursor/mcp.jsonfile becausecursor-agent mcphas 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-deltastreaming-json, which would break newline-joined text collection). Withauto_approve=True(the default) the adapter passes--always-approve.effortmaps to--reasoning-effort,allowed_toolsto--tools, anddisallowed_toolsto--disallowed-tools(canonicalbashmaps torun_terminal_cmd— the working deny id, not init.tools'run_terminal_command). The terminalresultevent carries cost (may be0on some auth paths), duration, and rawusage.output_tokens(reasoning is already inside that figure when reported). MCP add/remove/list are supported viagrok mcpwith 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
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 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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
db938dc658022b820bb43e9db3faf0746ebd6450cee7a78f0970ea91c2a69ac9
|
|
| MD5 |
d2793541cda94e1b2f57a18981c9db94
|
|
| BLAKE2b-256 |
0528089ed289750d7ea4120b9f1dd809a96e3984cb669c863459e9d6f0bdf8c5
|
Provenance
The following attestation bundles were made for agent_shell_py-0.3.2.tar.gz:
Publisher:
publish.yml on ScottRBK/agent-shell
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
agent_shell_py-0.3.2.tar.gz -
Subject digest:
db938dc658022b820bb43e9db3faf0746ebd6450cee7a78f0970ea91c2a69ac9 - Sigstore transparency entry: 2724004294
- Sigstore integration time:
-
Permalink:
ScottRBK/agent-shell@8cd7278bd61ac083ed56d22e8e8d52f82f508cdd -
Branch / Tag:
refs/tags/v0.3.2 - Owner: https://github.com/ScottRBK
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@8cd7278bd61ac083ed56d22e8e8d52f82f508cdd -
Trigger Event:
release
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
594b0bbfb4efbb435354a9d6fa0c7d20c5c20b4b9e35945ef5a83b2c6d892ccc
|
|
| MD5 |
4734aecc5c12b1f87b1556970ea0d21e
|
|
| BLAKE2b-256 |
dd285a628e4c2bcfd655ad21809bbdb3151f0ab183a8557238e1d8ef215e2d73
|
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
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
agent_shell_py-0.3.2-py3-none-any.whl -
Subject digest:
594b0bbfb4efbb435354a9d6fa0c7d20c5c20b4b9e35945ef5a83b2c6d892ccc - Sigstore transparency entry: 2724005016
- Sigstore integration time:
-
Permalink:
ScottRBK/agent-shell@8cd7278bd61ac083ed56d22e8e8d52f82f508cdd -
Branch / Tag:
refs/tags/v0.3.2 - Owner: https://github.com/ScottRBK
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@8cd7278bd61ac083ed56d22e8e8d52f82f508cdd -
Trigger Event:
release
-
Statement type: