External CLI Integrations
Drive external AI CLIs like Claude Code from a PraisonAI agent
Drive external AI CLIs from a PraisonAI agent — Claude Code is the flagship integration.
from praisonaiagents import Agent
from praisonai.integrations import ClaudeCodeIntegration
agent = Agent(
name="coder",
tools=[ClaudeCodeIntegration().as_tool()],
)
agent.start("Summarise the README in this repo")The user asks in chat; the agent invokes Claude Code or another external CLI through the integration tool.
Quick Start
- Attach Claude Code as an agent tool
python from praisonaiagents import Agent from praisonai.integrations import ClaudeCodeIntegration agent = Agent( name="coder", tools=[ClaudeCodeIntegration().as_tool()], ) agent.start("Summarise the README in this repo") - One-shot call via the integration API
python import asyncio from praisonai.integrations import ClaudeCodeIntegration integration = ClaudeCodeIntegration() result = asyncio.run(integration.execute("Summarise the README in this repo")) print(result) - Continue a previous Claude Code session
python # First turn await integration.execute("List the open bugs") # Continue the same session — you MUST pass continue_session=True explicitly await integration.execute("Now suggest fixes for the first one", continue_session=True) - Stream events live
python async for event in integration.stream("Refactor utils.py"): print(event) # parsed JSON events from the CLIThe
timeoutargument is enforced end-to-end:stream_async(timeout=...)applies a monotonic deadline to every stdout read and the final drain, so a stalledclaude/gemini/codex/cursorsubprocess raisesTimeoutError: Stream timed out after {timeout}s: {cmd}instead of hanging forever. - Per-call output format
python await integration.execute("prompt", output_format="json") await integration.execute("prompt", output_format="text") - Register as a native async tool
python from praisonaiagents import Agent from praisonai.integrations import ClaudeCodeIntegration agent = Agent( name="coder", tools=[ClaudeCodeIntegration().as_async_tool()], # awaited on the running loop ) # Use the async entrypoint on the agent to keep everything on one loop: await agent.astart("Summarise the README in this repo")as_async_tool()returns a coroutine tool named<cli>_atool(for exampleclaude_atool). It is functionally equivalent toas_tool(), but with no thread hop — measurable when the agent invokes the CLI many times in one turn.
How It Works
The integration builds CLI arguments dynamically per call, with no instance state mutated during execution.
Stateless Session Management
| Old (removed / ignored) | New explicit parameter |
|---|---|
self._session_active = True (auto) | continue_session=True per call |
self.output_format mutated during stream() | output_format="stream-json" passed per call |
integration.reset_session() | No longer needed — deprecated no-op |
Before (stateful):
# ❌ Old pattern — relied on implicit session state
integration = ClaudeCodeIntegration()
await integration.execute("step 1") # marks _session_active = True
await integration.execute("step 2") # implicitly continued previous session
integration.reset_session() # cleared stateAfter (stateless):
# ✅ New pattern — explicit, stateless, safe for concurrent use
integration = ClaudeCodeIntegration()
await integration.execute("step 1")
await integration.execute("step 2", continue_session=True) # explicit
# reset_session() is no longer needed; it is a deprecated no-opCLI: Manager Delegation (Default)
PraisonAI CLI offers two execution modes for external agents, providing flexibility between automated reasoning and direct execution.
| Mode | Flag | Behaviour | Best for |
|---|---|---|---|
| Manager delegation (default) | --external-agent X | Manager Agent wraps the CLI as a tool, reasons, then calls it | Multi-step tasks, planning, aggregation |
| Direct proxy | --external-agent X --external-agent-direct | Pass-through — CLI runs the prompt verbatim | Fast single-shot calls, scripting, when you don’t want a manager LLM |
Usage Examples:
# Manager delegation - manager reasons, then calls claude as tool
praisonai "Fix the bug in auth.py" --external-agent claude
# Direct proxy - no manager overhead, straight to claude
praisonai "Fix the bug in auth.py" --external-agent claude --external-agent-directConfiguration Options
| Option | Type | Default | Where | Description | ||
|---|---|---|---|---|---|---|
workspace | str | "." | constructor | Working directory passed to the CLI | ||
timeout | int | 300 | constructor | Per-call timeout (seconds), enforced on streaming reads — a stalled subprocess raises TimeoutError | ||
output_format | str | "json" | constructor or per-call | "text" \ | "json" \ | "stream-json" |
use_sdk | bool | False (True if SDK available) | constructor | Use the Claude Code SDK when installed, otherwise fall back to subprocess | ||
model | `str \ | None` | None | constructor | Passed through to the CLI via --model | |
continue_session | bool | False | per-call | Adds --continue to the CLI invocation | ||
skip_permissions | bool | True | constructor | Skip permission prompts with --dangerously-skip-permissions | ||
system_prompt | `str \ | None` | None | constructor | Custom system prompt to append | |
allowed_tools | `List[str] \ | None` | None | constructor | List of allowed tools (e.g., [“Read”, “Write”, “Bash”]) | |
disallowed_tools | `List[str] \ | None` | None | constructor | List of disallowed tools |
Registry
The ExternalAgentRegistry manages all available CLI integrations with a thread-safe registry pattern with lazy module-level default. You can construct your own ExternalAgentRegistry() for test isolation or multi-tenant runs:
from praisonai.integrations.registry import get_default_registry
# Default registry (recommended for app code)
registry = get_default_registry()
# List available integrations
available = await registry.get_available()
print(available) # {'claude': True, 'gemini': False, ...}
# Option 1 — soft lookup: returns None if the integration is missing/unavailable
claude = registry.try_create("claude", workspace="/path/to/project")
if claude is None:
print("Integration 'claude' not found or unavailable")
# Option 2 — strict: raises ValueError with diagnostics if not registered
claude = registry.create("claude", workspace="/path/to/project")The existing helpers get_registry(), register_integration(), create_integration(), and get_available_integrations() are still exported and still work — they now delegate to get_default_registry(). Backward-compat code does not need to change.
Invalidating the availability cache
BaseCLIIntegration caches the result of shutil.which(cli_command) for each CLI. If a CLI is installed/uninstalled mid-run (e.g. in tests, or after a setup step), invalidate the cache:
from praisonai.integrations.base import BaseCLIIntegration
# Clear cache for one command
BaseCLIIntegration.invalidate_availability("claude")
# Clear the entire cache
BaseCLIIntegration.invalidate_availability()The cache is thread-safe; invalidation is also thread-safe.
Logging for embedders
The registry logs through the named logger praisonai.integrations.registry — it never configures the root logger, so importing or calling the registry will not install a handler on an embedding application’s root logger.
Two warnings route through this logger:
| Message | When |
|---|---|
Failed to check availability for <name>: <error> | An integration raised while its availability was being probed |
Skipping external agent '<name>': failed to load (<error>) | An optional plugin failed to import and was dropped from the catalog |
Silence or redirect them:
import logging
logging.getLogger("praisonai.integrations.registry").setLevel(logging.ERROR)Registering the integration as a tool
Both methods wrap the same underlying integration.execute(query) coroutine — pick the one that matches how your agent is invoked.
| Method | Returns | Sync agent | Async agent | Registered name |
|---|---|---|---|---|
integration.as_tool() | def tool_func(query: str) -> str | ✅ | ✅ (safe since PraisonAI PR #4022) | <cli>_tool |
integration.as_async_tool() | async def atool_func(query: str) -> str | Not intended (coroutine) | ✅ Preferred — no thread hop | <cli>_atool |
<cli> is the integration’s cli_command: claude_tool/claude_atool, gemini_tool/gemini_atool, codex_tool/codex_atool, and cursor-agent_tool/cursor-agent_atool.
Register a custom external agent
Register your own external agent once and it becomes reachable from every surface — --external-agent, the praisonai_code CLI handler, and the UI toggles — with no PraisonAI code changes.
- Ship as a pip plugin (recommended)
Publish your integration class to the
praisonai.external_agentsentry-point group:toml # your-plugin/pyproject.toml [project.entry-points."praisonai.external_agents"] aider = "acme_aider:AiderIntegration"After
pip install your-plugin,aiderappears inpraisonai --external-agentchoices, in thepraisonai_codeCLI handler, and as a UI toggle — no PraisonAI code changes. - Or register at runtime
python from praisonai.integrations.registry import get_default_registry from praisonai.integrations.base import BaseCLIIntegration class AiderIntegration(BaseCLIIntegration): display_label = "Aider (pair programming)" install_hint = "pip install aider-chat" @property def cli_command(self) -> str: return "aider" async def execute(self, prompt: str, **options) -> str: ... async def stream(self, prompt: str, **options): ... get_default_registry().register("aider", AiderIntegration)register()raisesValueErrorif the class does not inheritBaseCLIIntegration.
Presentation metadata
Two optional class attributes drive help text, UI labels, and install hints on every surface:
| Attribute | Type | Default | Description | |
|---|---|---|---|---|
display_label | `str \ | None` | f"{name} CLI" | UI label and CLI help text |
install_hint | `str \ | None` | "See documentation" | Install instruction shown when the CLI is missing |
Built-ins declare them too:
| Name | display_label | install_hint |
|---|---|---|
claude | Claude Code (coding, file edits) | npm install -g @anthropic-ai/claude-code |
gemini | Gemini CLI (analysis, search) | npm install -g @anthropic-ai/gemini-cli |
codex | Codex CLI (refactoring) | npm install -g @openai/codex |
cursor | Cursor CLI (IDE tasks) | Download from cursor.com |
Single source of truth
Two registry helpers back every surface that lists external agents:
from praisonai.integrations.registry import (
list_external_agents,
external_agent_catalog,
)
# Names of every registered external agent (built-ins + plugins)
list_external_agents()
# ['claude', 'gemini', 'codex', 'cursor', 'aider']
# Presentation metadata per agent: {"cls", "label", "cli", "install"}
external_agent_catalog()["aider"]
# {'cls': AiderIntegration, 'label': 'Aider (pair programming)',
# 'cli': 'aider', 'install': 'pip install aider-chat'}Built-ins keep declaration order (claude, gemini, codex, cursor); plugins are appended in sorted order behind them. A faulty optional plugin is logged and skipped — it never blanks the catalog. Name collisions between a third-party plugin and a built-in are resolved in favour of the built-in — a plugin can extend the catalog but never replace claude, gemini, codex, or cursor.
A listed name is always a usable name. list_external_agents() is derived from external_agent_catalog(), so a plugin that fails to import disappears from both surfaces at once. A broken plugin is never advertised as a --external-agent choice and then rejected by the handler — if the name shows up in --help, in ExternalAgentsHandler().list_integrations(), or as a UI toggle, it loads.
Which entry-point group?
Two entry-point groups exist and are easy to confuse — pick by outcome:
| Group | Outcome | Documented in |
|---|---|---|
praisonai.external_agents | Short-name --external-agent choices, registry, and UI toggles | This page |
praisonai.integrations | Module importability (from praisonai.integrations import acme) — does not feed short-name --external-agent lookup | Integration Registry |
Use praisonai.external_agents for a selectable --external-agent short name and a UI toggle. Use praisonai.integrations only to make a class importable from praisonai.integrations.
Prerequisites
The claude CLI must be available on PATH or the Claude Code SDK must be installed if use_sdk=True. The integration performs a cached, thread-safe shutil.which(...) check via is_available().
# Install Claude Code CLI (option 1)
# Follow Claude Code installation instructions
# Or install SDK (option 2)
pip install claude-agent-sdkDecision Flow
Best Practices
Always pass continue_session=True explicitly
Don’t rely on instance state for session continuation. Always be explicit about when you want to continue a previous session.
# ✅ Good - explicit session control
await integration.execute("step 1")
await integration.execute("step 2", continue_session=True)
# ❌ Bad - relying on removed implicit state
await integration.execute("step 1")
await integration.execute("step 2") # No longer continues sessionSet output_format per call or once in constructor
Do not mutate the integration.output_format attribute between calls. Use the per-call parameter or set it once during initialization.
# ✅ Good - per-call parameter
await integration.execute("prompt", output_format="json")
await integration.execute("prompt", output_format="text")
# ✅ Good - set once in constructor
integration = ClaudeCodeIntegration(output_format="json")
# ❌ Bad - mutating instance attribute
integration.output_format = "json" # Don't do thisRemove reset_session() calls
The reset_session() method is now a no-op. Remove these calls from your code as they serve no purpose.
# ❌ Bad - calling deprecated no-op
integration.reset_session()
# ✅ Good - just omit the call
# Session state is automatically statelessSafe for concurrent use
A single ClaudeCodeIntegration instance can be shared across concurrent tasks safely since no instance state is mutated during calls.
# ✅ Good - concurrent safe
integration = ClaudeCodeIntegration()
async def task1():
return await integration.execute("task 1")
async def task2():
return await integration.execute("task 2")
# Both tasks can run concurrently safely
results = await asyncio.gather(task1(), task2())Troubleshooting
My plugin doesn't appear in –external-agent
The plugin failed to import, so it was dropped from the catalog and never offered as a choice. The reason is logged as Skipping external agent '<name>': failed to load (<error>) on logger praisonai.integrations.registry.
See the exact error:
import logging
logging.basicConfig(level=logging.WARNING)
from praisonai.integrations.registry import list_external_agents
list_external_agents()Fix the import error the log reports (a missing dependency, a bad entry-point path, a syntax error in the plugin), then re-run — the name reappears once the class loads.
–external-agent says the wrapper is too old
You see:
your installed 'praisonai' wrapper is too old for --external-agent (missing external_agent_catalog); upgrade with: pip install -U praisonaiThis means your praisonai-code is newer than your praisonai wrapper — a mixed-version install. Upgrade the wrapper:
pip install -U praisonai