Archived — This migration is complete. This document is kept for historical reference only.
The claude_agent_sdk / Claude Code CLI is no longer available. This plan replaces every
SDK dependency with direct Anthropic API calls using the anthropic Python package.
The goal is full functional equivalence: the 8-agent orchestration pipeline continues to work exactly as before — analyze → spec → plan → code → test → review → submit — but the underlying execution engine shifts from the CLI to the API with a self-contained tool loop.
| Component | Before | After |
|---|---|---|
| Agent execution | ClaudeSDKClient (CLI subprocess) |
AnthropicAPIClient (HTTP + tool loop) |
| Tool execution | CLI handles natively | Python executors in agents/tools.py |
| Agentic loop | CLI manages internally | Implemented in AnthropicAPIClient |
| Pre-tool security | SDK HookMatcher / HookCallback |
Direct call to validate_bash_command() |
| Message types | SDK AssistantMessage, TextBlock, etc. |
Local dataclasses in agents/types.py |
AgentDefinition |
SDK class | Local dataclass |
| Token tracking | ~/.claude/stats-cache.json + RateLimitEvent |
API response.usage |
| Auth | Claude Code CLI session | ANTHROPIC_API_KEY env var |
requirements.txt |
claude-agent-sdk>=0.1.0 |
anthropic>=0.40.0 |
| File | Change |
|---|---|
agents/types.py |
New — local message type dataclasses |
agents/tools.py |
New — tool definitions + Python executors |
agents/anthropic_client.py |
New — AnthropicAPIClient with full agentic loop |
agents/orchestrator.py |
Modify — remove SDK imports, wire new client |
agents/definitions.py |
Modify — remove SDK AgentDefinition, use local dataclass |
agents/codex_client.py |
Modify — import types from agents/types not SDK |
security.py |
Modify — remove SDK imports, keep pure-Python logic |
token_tracker.py |
Modify — replace SDK rate-limit tracking with API usage |
config.py |
Modify — add anthropic_api_key, max_tokens_per_agent |
requirements.txt |
Modify — swap claude-agent-sdk for anthropic |
Local replacements for all SDK message/block types. These are used in both
AnthropicAPIClient and CodexClient so that isinstance() checks in
_run_agent() continue to work without touching that function.
from dataclasses import dataclass, field
@dataclass
class TextBlock:
type: str # always "text"
text: str
@dataclass
class ToolUseBlock:
"""Represents an assistant tool-call block. Has a .name attr so the
existing `elif hasattr(block, "name")` check in _run_agent keeps working."""
type: str # always "tool_use"
name: str
id: str
input: dict
@dataclass
class AssistantMessage:
role: str # always "assistant"
content: list # list of TextBlock | ToolUseBlock
@dataclass
class ResultMessage:
type: str # "result"
subtype: str # "success" | "error"
is_error: bool = False
@dataclass
class RateLimitEvent:
"""Placeholder — Anthropic API does not emit rate-limit stream events.
Kept so _run_agent's isinstance branch is never triggered (we just never yield it)."""
passTool JSON schema definitions and Python executor functions for all 6 tools.
Each tool needs an Anthropic API-compatible schema dict:
TOOL_DEFINITIONS = {
"Read": {
"name": "Read",
"description": "Read a file from the filesystem and return its contents.",
"input_schema": {
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "Absolute path to the file."},
"limit": {"type": "integer", "description": "Max lines to read."},
"offset": {"type": "integer", "description": "Line number to start from."},
},
"required": ["file_path"],
},
},
"Write": {
"name": "Write",
"description": "Write content to a file, overwriting it if it exists.",
"input_schema": {
"type": "object",
"properties": {
"file_path": {"type": "string"},
"content": {"type": "string"},
},
"required": ["file_path", "content"],
},
},
"Edit": {
"name": "Edit",
"description": "Replace an exact string in a file with new content.",
"input_schema": {
"type": "object",
"properties": {
"file_path": {"type": "string"},
"old_string": {"type": "string"},
"new_string": {"type": "string"},
"replace_all": {"type": "boolean", "default": False},
},
"required": ["file_path", "old_string", "new_string"],
},
},
"Glob": {
"name": "Glob",
"description": "Find files matching a glob pattern. Returns sorted file paths.",
"input_schema": {
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "Glob pattern e.g. '**/*.py'"},
"path": {"type": "string", "description": "Directory to search in (default: cwd)"},
},
"required": ["pattern"],
},
},
"Grep": {
"name": "Grep",
"description": "Search file contents using ripgrep or Python regex fallback.",
"input_schema": {
"type": "object",
"properties": {
"pattern": {"type": "string"},
"path": {"type": "string"},
"glob": {"type": "string", "description": "File filter e.g. '*.py'"},
"-i": {"type": "boolean", "description": "Case insensitive"},
},
"required": ["pattern"],
},
},
"Bash": {
"name": "Bash",
"description": "Run a shell command and return stdout + stderr.",
"input_schema": {
"type": "object",
"properties": {
"command": {"type": "string"},
"timeout": {"type": "number", "description": "Timeout in seconds (default 120)"},
},
"required": ["command"],
},
},
}def execute_tool(name: str, tool_input: dict, cwd: str) -> tuple[str, bool]:
"""
Execute a tool and return (output_text, is_error).
Security validation for Bash is handled here before subprocess execution.
"""
try:
if name == "Read": return _run_read(tool_input, cwd), False
if name == "Write": return _run_write(tool_input, cwd), False
if name == "Edit": return _run_edit(tool_input, cwd), False
if name == "Glob": return _run_glob(tool_input, cwd), False
if name == "Grep": return _run_grep(tool_input, cwd), False
if name == "Bash": return _run_bash(tool_input, cwd), False
return f"Unknown tool: {name}", True
except Exception as exc:
return f"Tool {name} failed: {exc}", True_run_read — Path.read_text() with optional line slicing (limit/offset).
_run_write — Path.write_text() after creating parent dirs.
_run_edit — Load file, validate old_string exists exactly once (or use replace_all),
replace and write back. Return error string if old_string not found.
_run_glob — pathlib.Path.rglob() / glob(). Sort by modification time to match
Claude Code's Glob behavior.
_run_grep — Try subprocess ripgrep (rg) first; fall back to Python re scan.
Return matching file paths or file:line:content depending on mode.
_run_bash:
- Call
validate_bash_command(command)fromsecurity.py - If blocked, return the block reason as an error
- Otherwise
asyncio.create_subprocess_shell()with timeout (default 120s) - Return combined stdout + stderr
Note on Bash executor:
_run_bashmust be async (subprocess). The tool executor dispatch must beasync def execute_tool(...)and awaited in the loop.
The core of the migration: a client that runs the full agentic loop.
import anthropic
from agents.types import (
AssistantMessage, ResultMessage, RateLimitEvent, TextBlock, ToolUseBlock
)
from agents.tools import TOOL_DEFINITIONS, execute_tool
@dataclass
class AnthropicAPIClientOptions:
system_prompt: str
model: str # full model ID e.g. "claude-sonnet-4-6"
tools: list[str] # subset of TOOL_DEFINITIONS keys
cwd: str
max_tokens: int = 16384
api_key: str | None = None # falls back to ANTHROPIC_API_KEY env varclass AnthropicAPIClient:
async def __aenter__(self): return self
async def __aexit__(self, *_): pass
async def query(self, prompt): self._task_prompt = prompt
async def receive_response(self): # async generator
client = anthropic.AsyncAnthropic(api_key=self._options.api_key)
tool_defs = [TOOL_DEFINITIONS[t] for t in self._options.tools if t in TOOL_DEFINITIONS]
messages = [{"role": "user", "content": self._task_prompt}]
while True:
kwargs = dict(
model=self._options.model,
max_tokens=self._options.max_tokens,
system=self._options.system_prompt,
messages=messages,
)
if tool_defs:
kwargs["tools"] = tool_defs
response = await client.messages.create(**kwargs)
# Build assistant message for yielding (text + tool_use blocks)
content_blocks = []
for block in response.content:
if block.type == "text":
content_blocks.append(TextBlock(type="text", text=block.text))
elif block.type == "tool_use":
content_blocks.append(ToolUseBlock(
type="tool_use", name=block.name, id=block.id, input=block.input
))
yield AssistantMessage(role="assistant", content=content_blocks)
if response.stop_reason == "end_turn" or not tool_defs:
# Store usage on self for token tracking
self.last_usage = response.usage
yield ResultMessage(type="result", subtype="success")
return
# Execute all tool_use blocks and collect results
tool_results = []
for block in response.content:
if block.type != "tool_use":
continue
output, is_error = await execute_tool(block.name, block.input, self._options.cwd)
yield ResultMessage(
type="result",
subtype="error" if is_error else "success",
is_error=is_error,
)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
"is_error": is_error,
})
# Add both sides of the exchange to history and loop
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": tool_results})Key design notes:
- Uses
anthropic.AsyncAnthropicto stay on the asyncio event loop - Yields an
AssistantMessagefor each assistant turn (not just the final one) so_run_agentlogs intermediate tool calls correctly - Stores
last_usageafter the final turn for token tracking - No
RateLimitEventis yielded — the existingisinstance(message, RateLimitEvent)branch in_run_agentsimply never fires - Tool-less agents (planner, spec-writer, spec-reviewer) get
tools=[], loop exits after firstend_turn
Remove:
from claude_agent_sdk import (AssistantMessage, ClaudeAgentOptions, ClaudeSDKClient,
ResultMessage, RateLimitEvent)
from claude_agent_sdk.types import (HookCallback, HookMatcher, TextBlock)Add:
from agents.anthropic_client import AnthropicAPIClient, AnthropicAPIClientOptions
from agents.types import AssistantMessage, ResultMessage, RateLimitEvent, TextBlockHookCallback, HookMatcher, and _BASH_HOOKS are removed entirely — Bash security
is now enforced inside the _run_bash tool executor.
Add "anthropic" as a third backend option alongside "claude" and "codex".
Make "anthropic" the new default (replacing "claude"):
def _make_agent_client(..., agent_type: str = "") -> AnthropicAPIClient | CodexClient:
backend = per_agent_backend or settings.agent_backend # default now "anthropic"
if backend == "anthropic":
return AnthropicAPIClient(AnthropicAPIClientOptions(
system_prompt=system_prompt,
model=model, # full ID from AGENT_MODELS — already correct
tools=tools,
cwd=str(repo_path),
max_tokens=settings.max_tokens_per_agent,
api_key=settings.anthropic_api_key or None,
))
if backend == "codex":
... # unchanged
# "claude" kept for backward compat — raises if CLI is unavailable
return ClaudeSDKClient(...)This function writes .claude_settings.json which is only consumed by the Claude Code
CLI. It can be deleted, along with the self.settings_file references in IssueWorkflow.
The settings_file parameter to _make_agent_client can be made optional
(settings_file: Path | None = None) and only used when backend == "claude".
All 4 agents that currently pass hooks=_BASH_HOOKS (coder, tester, reviewer,
github-submitter) no longer need it. Bash security is handled inside _run_bash.
Change client: ClaudeSDKClient → client: AnthropicAPIClient | CodexClient.
The body is unchanged — duck typing still works.
After _run_agent returns, check if the client has last_usage:
usage = getattr(client, "last_usage", None)
# pass to record_usage() / print_usage_summary()Remove SDK AgentDefinition import. Replace with a local dataclass:
from dataclasses import dataclass
@dataclass
class AgentDefinition:
description: str
prompt: str
tools: list[str]
model: str # full model ID now, not shortname
# Remove _shortname() — no longer needed
# Update make_*() functions to pass full model IDsReplace SDK type imports:
# Remove:
from claude_agent_sdk import AssistantMessage, ResultMessage
from claude_agent_sdk.types import TextBlock
# Add:
from agents.types import AssistantMessage, ResultMessage, TextBlockNo other changes needed.
Remove SDK imports entirely. The validate_bash_command() function is pure Python —
no SDK types needed. The bash_security_hook async function (SDK hook signature) is
deleted; security enforcement moves into agents/tools.py::_run_bash.
# Remove these two lines:
from claude_agent_sdk import PreToolUseHookInput
from claude_agent_sdk.types import HookContext, SyncHookJSONOutput
# Remove:
async def bash_security_hook(...) -> SyncHookJSONOutput: ...
# Keep:
ALLOWED_BASE_COMMANDS, ALLOWED_GIT_SUBCOMMANDS
_extract_commands(), _validate_git_subcommand(), validate_bash_command()The two SDK-dependent parts:
-
~/.claude/stats-cache.json— Claude Code CLI file, no longer written. Removeread_stats_cache(),_sum_daily_tokens(), and the stats-cache section of_print_summary(). Replace with per-run usage fromAnthropicAPIClient.last_usage. -
RateLimitEventhandling —_latest_rate_limit_events()and the rate-limit section of_print_summary()consume SDK event objects. Remove these.
The record_usage() function and UsageRecord dataclass are unchanged — they
already accept a plain dict. Populate from AnthropicAPIClient.last_usage:
# anthropic SDK response.usage fields:
usage_dict = {
"input_tokens": response.usage.input_tokens,
"output_tokens": response.usage.output_tokens,
"cache_creation_input_tokens": getattr(response.usage, "cache_creation_input_tokens", 0),
"cache_read_input_tokens": getattr(response.usage, "cache_read_input_tokens", 0),
}A basic per-run cost estimate can be derived from token counts and known pricing.
Add:
# Anthropic API
anthropic_api_key: str = "" # Falls back to ANTHROPIC_API_KEY env var if empty
max_tokens_per_agent: int = 16384 # Max output tokens per agent API call
# Update default backend
agent_backend: str = "anthropic" # was "claude"pydantic>=2.7.0
pydantic-settings>=2.3.0
anthropic>=0.40.0
python-dotenv>=1.0.1
Remove claude-agent-sdk>=0.1.0.
✅agents/types.py— unblocks everything else✅security.py— remove SDK dependency, keep all logic✅agents/tools.py— tool schemas + executors (depends on security.py)✅agents/anthropic_client.py— agentic loop (depends on types + tools)✅agents/codex_client.py— swap type imports✅agents/definitions.py— local AgentDefinition✅agents/orchestrator.py— wire new client, remove hooks/settings_file✅token_tracker.py— simplify to API usage only✅config.py— add fields, change default backend✅requirements.txt— swap dependency
The agentic loop appends every turn to messages. Long coding sessions will eventually
hit the model's context window limit (~200K tokens for Sonnet). Add a safeguard: if
len(messages) grows beyond a threshold, truncate middle turns while preserving the
first user message and the last N turns.
The Claude Code Edit tool requires old_string to appear exactly once. Our Python
implementation must enforce this and return an error if not found or found multiple times
(unless replace_all=True). If agents currently rely on the exact error messages from
the CLI, prompt adjustments may be needed.
_run_bash uses asyncio.create_subprocess_shell. The execute_tool dispatcher must
be async def and awaited in the receive_response loop.
AGENT_MODELS already stores full IDs ("claude-sonnet-4-6", etc.). The _shortname()
conversion in definitions.py was only needed for the SDK's AgentDefinition. It should
be removed — all API calls use full IDs directly.
These will become dead code once the "claude" backend is no longer the default.
Remove them to avoid confusion. The .claude_settings.json file in worktrees can be
left as-is; it won't cause errors if it exists but nothing reads it.
The Codex backend path is unaffected by this migration. It will import its message types
from agents/types.py after Step 6 above.
Keep the ClaudeSDKClient path in _make_agent_client behind backend == "claude"
in case the CLI becomes available again. It will simply raise ImportError on missing
SDK at runtime.
- Unit test tools: Manually call each tool executor with known inputs/outputs.
- Unit test security: Confirm
_run_bashblocks disallowed commands and allows permitted ones. - Dry-run tool-less agent: Run
_run_plannerwithAGENT_BACKEND=anthropic— no tools, single API call, verify text response collected. - Dry-run coder agent: Run
_run_coderon a trivial task — verify tool calls appear in logs and files are actually modified. - Full issue end-to-end: Run against a real GitHub issue with
AGENT_BACKEND=anthropic. - Token tracking: Verify
record_usage()is called with non-zero token counts. - Codex still works: Set
AGENT_BACKEND=codex, confirm it still routes correctly.