Date: 2026-02-19
Last Updated: 2026-02-20 (post CLI backend removal)
SDK Version: claude-agent-sdk ^0.1.38
Codebase Module: src/claude/ (~1,500 lines across 6 files)
This document captures the findings from a deep review of the src/claude/ module
against the actual capabilities of the Claude Agent SDK. The goal is to identify
where we're duplicating SDK functionality, over-complicating things, or missing
native features that would simplify the codebase.
The SDK reference used: https://platform.claude.com/docs/en/agent-sdk/python
- Executive Summary
- Finding 1: Using
query()Instead ofClaudeSDKClient - Finding 2: Tool Validation Duplicates
can_use_tooland Hooks - Finding 3: Dual Backend (SDK + CLI Subprocess)
- Finding 4: No Use of
max_budget_usd - Finding 5: Manual
disallowed_toolsChecking - Finding 6: Bash Pattern Blocklist vs Sandbox +
can_use_tool - Finding 7: CLI Path Discovery
- Finding 8: Manual Content Extraction vs
ResultMessage.result - Finding 9: Dead In-Memory Session State
- Estimated Line Reduction
- Recommended Refactor Order
- Migration Risks
- Progress Log
Approximately 61% (~1,700 lines) of the src/claude/ module duplicates or
works around functionality the SDK already provides natively. The three highest
impact issues are:
- Using the stateless
query()API then building session management on top, whenClaudeSDKClientprovides stateful multi-turn conversations natively. - Implementing reactive tool validation during streaming, when the SDK's
can_use_toolcallback blocks tools before execution. - Maintaining a full CLI subprocess fallback backend that duplicates everything the SDK does.
Impact: HIGH | Files: session.py, facade.py, sdk_integration.py
Status: PARTIALLY COMPLETE (PR #56, merged 2026-02-20)
The SDK has two APIs (see official comparison table):
| Feature | query() |
ClaudeSDKClient |
|---|---|---|
| Session | New each time | Reuses same session |
| Conversation | Single exchange | Multiple exchanges in same context |
| Interrupts | Not supported | Supported |
| Hooks | Not supported | Supported |
| Custom Tools | Not supported | Supported |
| Continue Chat | New session each time | Maintains conversation |
ClaudeSDKClient is purpose-built for our use case:
async with ClaudeSDKClient(options) as client:
await client.query("first message")
async for msg in client.receive_response():
process(msg)
# Follow-up -- Claude remembers everything above
await client.query("follow up question")
async for msg in client.receive_response():
process(msg)We use query() (the one-shot API) and then build a 340-line SessionManager
on top of it:
- Temporary session IDs (
session.py:204-215): We generatetemp_*UUIDs because we don't have a session ID until Claude responds. - Session ID swapping (
session.py:236-257): After the first response, we delete the temp session and re-store under Claude's real ID. - Resume logic (
facade.py:149-155): Complex checks foris_new_session,temp_*prefix detection, and conditionaloptions.resumepassing. - Auto-resume search (
facade.py:349-374): Scans all user sessions to find one matching the current directory. - Stale session retry (
facade.py:165-192): If resume fails with "no conversation found", catches the error, cleans up, and retries fresh. - In-memory + SQLite dual storage: Sessions are kept in both
SessionManager.active_sessionsdict andSessionStorage(SQLite). - Abstract
SessionStoragebase class +InMemorySessionStorageimplementation that exists for testing but adds indirection.
With ClaudeSDKClient:
- No temporary session IDs needed (client manages its own session)
- No session swapping logic
- No resume/retry dance
- Session ID is available immediately from any
ResultMessage - Only need thin persistence: store
{user_id, directory, session_id}in SQLite so we can resume across bot restarts viaoptions.resume
- Migrated from
query()toClaudeSDKClient—sdk_integration.pynow usesasync with ClaudeSDKClient(options) as clientfor each request - Eliminated
temp_*session IDs — new sessions usesession_id=""with deferred storage save until Claude responds with a real ID - Removed session ID swapping —
update_session()now takes aClaudeSessionobject directly - Simplified facade — post-execution flow no longer does delete-old/save-new
SessionManageris still 342 lines (target: ~90 lines of thin persistence)SessionStorageABC andInMemorySessionStoragestill exist- Auto-resume search and stale session retry logic still present in facade
- Not yet using
ClaudeSDKClientfor multi-turn within a single connection (currently creates a new client per request)
session.py: ~250 of 340 lines removable (keepClaudeSessiondataclass as thin storage model, removeSessionStorageABC,InMemorySessionStorage, most ofSessionManager)facade.py: ~80 lines of session orchestration removablesdk_integration.py: session-related code simplifies
Impact: HIGH | Files: monitor.py, facade.py
Status: COMPLETE (Phase 3 branch, 2026-02-20)
The SDK has a native permission evaluation pipeline:
Hooks → Deny Rules → Allow Rules → Ask Rules → Permission Mode → can_use_tool callback
The can_use_tool callback runs before a tool executes and can deny or
modify the call:
async def permission_handler(tool_name, input_data, context):
if tool_name == "Write" and "/system/" in input_data.get("file_path", ""):
return PermissionResultDeny(message="System dir write blocked", interrupt=True)
if tool_name == "Bash":
cmd = input_data.get("command", "")
ok, err = check_boundary(cmd, working_dir, approved_dir)
if not ok:
return PermissionResultDeny(message=err)
return PermissionResultAllow(updated_input=input_data)
options = ClaudeAgentOptions(
can_use_tool=permission_handler,
allowed_tools=["Read", "Write", "Bash"],
disallowed_tools=["WebFetch"],
)Key capabilities:
- Pre-execution: Blocks tools before they run (not after)
- Input modification: Can rewrite tool inputs (e.g. redirect paths)
allowed_tools/disallowed_tools: Declarative tool filteringPermissionResultDeny.interrupt: Can halt the entire executionPreToolUsehooks: Even more granular control with pattern matching
ToolMonitor class (monitor.py, 333 lines):
validate_tool_call()(lines 145-281): Checks allowed/disallowed tools, validates file paths viaSecurityValidator, scans bash commands for dangerous patterns, checks directory boundaries.check_bash_directory_boundary()(lines 69-130): Parses bash withshlex, categorizes commands as read-only vs modifying, resolves paths.- In-memory
tool_usagecounter andsecurity_violationslist. get_tool_stats(),get_security_violations(),get_user_tool_usage().
Facade streaming interception (facade.py:93-138):
- Wraps the stream callback to intercept
StreamUpdateobjects - Validates tool calls during streaming (reactive, not preventive)
- On validation failure, raises
ClaudeToolValidationError— but the tool may have already started executing
Error message generation (facade.py:471-568):
_get_admin_instructions(): 60 lines generating.envconfiguration hints_create_tool_error_message(): 37 lines formatting blocked-tool messages
The current approach is reactive: it validates during streaming, meaning
the tool call has already been sent to Claude by the time we check it. The SDK's
can_use_tool is preventive: it blocks before execution.
- Create a single
can_use_toolcallback that encapsulates:- Path validation (from
SecurityValidator) - Directory boundary checks (from
check_bash_directory_boundary) - Any remaining custom security logic
- Path validation (from
- Pass
allowed_toolsanddisallowed_toolsdirectly toClaudeAgentOptions - Remove
ToolMonitorclass entirely - Remove streaming interception from facade
- If tool usage analytics are needed, use a
PostToolUsehook instead of in-memory counters
monitor.py: ~280 of 333 lines removable (keepcheck_bash_directory_boundaryas a utility if needed by thecan_use_toolcallback)facade.py: ~145 lines of interception + error messaging removable
Impact: HIGH | Files: integration.py, parser.py, facade.py
Status: COMPLETE (branch finding3/remove-cli-subprocess-backend, 2026-02-20)
- Deleted
integration.py(594 lines) andparser.py(338 lines) - Deleted
tests/unit/test_claude/test_parser.py(127 lines) - Removed fallback logic from
facade.py(_execute_with_fallback→_execute) - Removed
process_managerparameter fromClaudeIntegration.__init__() - Removed
use_sdkconfig flag fromSettings - Removed
_sdk_failed_counttracker - Single
ClaudeResponse/StreamUpdatedefinition insdk_integration.py - Updated all imports across
src/andtests/to usesdk_integration - ~1,060 net lines removed
Impact: MEDIUM | Files: session.py, sdk_integration.py
options = ClaudeAgentOptions(
max_budget_usd=5.00, # Hard cap per query
)This is enforced by the SDK itself — the query stops if the budget is exceeded.
Cost is tracked in four places with no enforcement:
ClaudeSession.total_cost— accumulated inupdate_usage()(session.py:52)ClaudeResponse.cost— returned from both SDK and CLI backendsResultMessage.total_cost_usd— SDK native field- SQLite
cost_trackingtable — historical storage
None of these enforce a limit. They only report after the fact.
- Set
max_budget_usdinClaudeAgentOptionsfor per-query cost caps - Keep SQLite tracking for historical reporting/dashboards
- Consider adding a config setting like
max_cost_per_querythat maps to this
Impact: MEDIUM | Files: monitor.py
Status: COMPLETE (branch finding3/remove-cli-subprocess-backend, 2026-02-20)
disallowed_tools is now passed directly to ClaudeAgentOptions in
sdk_integration.py, so the SDK enforces it before any tool executes.
The redundant ToolMonitor runtime check was removed in Phase 3.
Impact: MEDIUM | Files: monitor.py
Status: COMPLETE (Phase 3 branch, 2026-02-20)
ToolMonitor (lines 228-258) blocks bash commands containing these substrings:
dangerous_patterns = [
"rm -rf", "sudo", "chmod 777", "curl", "wget",
"nc ", "netcat", ">", ">>", "|", "&", ";", "$(", "`",
]>blocks all redirects — includingecho "hello" > file.txt|blocks all pipes — includinggrep pattern | sort&blocks background processes and&&chaining;blocks multi-command lines — includingcd dir; lscurl/wgetmay be legitimate for development work$(and`blocks command substitution — includingecho "Today is $(date)"
This effectively prevents Claude from doing useful shell work in many scenarios.
- Sandbox — OS-level isolation for filesystem and network
can_use_tool— semantic, pre-execution validationPreToolUsehooks — pattern-matched interception with deny capability
- Remove the substring blocklist
- Use
can_use_toolfor semantic validation (what is the command actually doing?) - Rely on the sandbox for OS-level enforcement
- Keep
check_bash_directory_boundary()as a utility for thecan_use_toolcallback — its approach (parsing withshlex, checking resolved paths) is more sound than substring matching
Impact: LOW | Files: sdk_integration.py
find_claude_cli() (lines 46-86) searches:
- Config/env
CLAUDE_CLI_PATH shutil.which("claude")~/.nvm/versions/node/*/bin/claude~/.npm-global/bin/claude~/node_modules/.bin/claude/usr/local/bin/claude,/usr/bin/claude~/AppData/Roaming/npm/claude.cmd(Windows)
update_path_for_claude() (lines 89-104) then modifies os.environ["PATH"].
ClaudeAgentOptions.cli_path — if set, the SDK uses it. Otherwise the SDK has
its own internal discovery.
- Only set
cli_pathif explicitly configured - Remove
find_claude_cli()andupdate_path_for_claude()(~60 lines) - If the SDK can't find the CLI, it raises
CLINotFoundError— handle that with a helpful error message
Impact: LOW | Files: sdk_integration.py
Status: COMPLETE (PR #56, merged 2026-02-20)
_extract_content_from_messages() (lines 435-451) iterates all messages and
joins TextBlock.text values from AssistantMessage objects.
ResultMessage has a result field containing the final text output:
for message in messages:
if isinstance(message, ResultMessage):
final_text = message.result # Already availableUse ResultMessage.result directly. Fall back to content extraction only if
result is None.
PR #56 now uses ResultMessage.result as the primary content source with
fallback to _extract_content_from_messages() when result is None.
Impact: LOW | Files: sdk_integration.py
Status: COMPLETE (PR #56, merged 2026-02-20)
ClaudeSDKManager.active_sessions (line 137) stores full message lists:
self.active_sessions[session_id] = {
"messages": messages,
"created_at": ...,
"last_used": ...,
}This data is never read back. The only consumer is kill_all_processes()
which just calls .clear(), and get_active_process_count() which returns the
dict length.
Remove active_sessions, _update_session(), and related methods (~20 lines).
PR #56 removed active_sessions dict and _update_session() from
ClaudeSDKManager. The in-memory session state no longer exists.
| File | Original | Current | Still Removable | Reason |
|---|---|---|---|---|
integration.py |
0 | — | ✅ Deleted | |
parser.py |
0 | — | ✅ Deleted | |
session.py |
340 | 342 | ~250 | Keep thin persistence model |
monitor.py |
333 | ~110 | — | ✅ ToolMonitor deleted, kept check_bash_directory_boundary() |
facade.py |
568 | ~280 | — | ✅ Removed interception, admin messages, tool_monitor |
sdk_integration.py |
513 | ~530 | ~60 | Added can_use_tool callback; remove CLI discovery |
exceptions.py |
50 | ~30 | — | ✅ Removed ClaudeToolValidationError |
| Total | 2,774 | ~1,290 | ~310 | ~24% remaining reduction |
Completed so far: ~1,480 net lines removed across PR #56, F3/F5, and Phase 3.
Post-refactor, the src/claude/ module should be roughly ~800 lines with
clearer responsibilities:
sdk_integration.py— Thin wrapper aroundClaudeSDKClient, builds options,can_use_toolcallback for path/boundary enforcementmonitor.py—check_bash_directory_boundary()utility used bycan_use_toolsession.py— Thin persistence (SQLite read/write of session IDs)facade.py— Simplified public API for bot handlersexceptions.py— Minimal custom exceptions
These steps are ordered to minimize risk and allow incremental progress. Each step should be a separate PR that can be tested independently.
-
Remove dead in-memory state from✅ DONE (PR #56) —ClaudeSDKManageractive_sessionsand_update_session()removed. -
Use✅ DONE (PR #56) — UsesResultMessage.resultfor content extractionResultMessage.resultwith fallback. -
Pass✅ DONE (F3/F5 branch) — Added todisallowed_toolsto SDK optionsClaudeAgentOptions().
Delete✅ DONE (F3/F5 branch) — Deleted both files, removed fallback logic, removedintegration.pyandparser.pyuse_sdkconfig flag, updated all imports. ~1,060 lines removed.
-
Implement✅ DONE (Phase 3 branch) —can_use_toolcallback_make_can_use_tool_callback()insdk_integration.pyencapsulates path validation and directory boundary checks. Wired intoClaudeAgentOptionsviaSecurityValidatorinjection intoClaudeSDKManager. Usesconnect(None)+query(prompt)pattern to satisfy SDK'sAsyncIterablerequirement forcan_use_tool. -
Remove✅ DONE (Phase 3 branch) — DeletedToolMonitorand facade interceptionToolMonitorclass frommonitor.py(keptcheck_bash_directory_boundary()), removedstream_handlerwrapper,_get_admin_instructions(),_create_tool_error_message(),ClaudeToolValidationError. Removed dead catch blocks from orchestrator and message handler. ~350 net lines removed.
-
Replace
query()withClaudeSDKClient⚡ PARTIALLY DONE (PR #56) — Core migration complete:- ✅
ClaudeSDKManagernow usesClaudeSDKClientper request - ✅ Temporary
temp_*session IDs eliminated - ✅ Session ID swapping logic removed
- ❌
SessionManagernot yet slimmed to thin persistence (~342 lines remain) - ❌
SessionStorageABC /InMemorySessionStoragestill present - ❌ Not yet using persistent
ClaudeSDKClientconnections for multi-turn - Remaining: ~200 lines removable from session.py + facade.py
- ✅
-
Add
max_budget_usd- Add config setting and pass to options
- ~10 lines
- No risk
-
Remove
find_claude_cli()andupdate_path_for_claude()- Let SDK handle discovery, only pass
cli_pathif configured - ~60 lines removed
- Let SDK handle discovery, only pass
-
Consolidate dataclasses
- Single
ClaudeResponsedefinition (or use SDK types directly) - Single
StreamUpdatedefinition (or eliminate if usingClaudeSDKClient)
- Single
We're on v0.1.31, latest is newer. The SDK is pre-1.0 and API surface may
shift. Before starting Phase 2+:
- Pin to a specific tested version
- Read changelogs between versions
- Run full test suite after upgrade
ClaudeSDKClient uses async with context manager. We need to manage client
lifecycle carefully:
- One client per user? Per user+directory? Global pool?
- What happens when the client disconnects unexpectedly?
- How do we handle bot restarts (need
resumeoption)?
Recommend prototyping this before committing to the refactor.
Moving from ToolMonitor to can_use_tool changes validation from reactive
to preventive, which is better. But the transition must be careful:
- Write tests for every validation rule in
ToolMonitorfirst - Ensure the
can_use_toolcallback covers all cases - Test edge cases (path traversal, command injection, etc.)
Before any refactor:
- Ensure existing tests pass
- Add integration tests for the SDK path (if not already present)
- Add tests for
can_use_toolcallback behavior
| Date | PR | Findings Addressed | Summary |
|---|---|---|---|
| 2026-02-20 | #56 | F1 (partial), F8, F9 | Migrated query() → ClaudeSDKClient, eliminated temp_* IDs and session swapping, uses ResultMessage.result, removed dead active_sessions state |
| 2026-02-20 | #59 | F3 (complete), F5 (complete) | Deleted CLI subprocess backend (integration.py, parser.py), removed use_sdk flag, passed disallowed_tools to SDK, ~1,060 lines removed |
| 2026-02-20 | Phase 3 branch | F2 (complete), F6 (complete) | Replaced ToolMonitor with SDK's can_use_tool callback, removed bash pattern blocklist, removed facade interception + admin message helpers, removed ClaudeToolValidationError, ~350 lines removed |
The recommended next action is Phase 4, step 7 — slim down SessionManager
to thin persistence (~250 lines removable). After that, add max_budget_usd
(step 8) and remove CLI path discovery (Phase 5, step 9).