Claude Agent SDK Hooks vs fasthooks¶
The Claude Agent SDK provides in-process hook callbacks for applications that embed Claude. This page compares SDK hooks with fasthooks.
Architecture¶
| Aspect | Claude Agent SDK | fasthooks |
|---|---|---|
| Execution Model | In-process async callback | Subprocess per hook call |
| Protocol | Bidirectional stream (SDK ↔ CLI) | Stdin/stdout JSON |
| Context | Runs inside SDK application | Spawned by Claude Code CLI |
| Async | Required (async def) |
Optional |
SDK hooks run inside your Python application alongside the SDK client. The CLI sends hook events over a bidirectional stream, and your callback responds immediately.
fasthooks runs as a separate process spawned by Claude Code. Each hook invocation starts a fresh process, reads JSON from stdin, and writes the response to stdout.
Event Coverage¶
| Event | SDK | fasthooks |
|---|---|---|
| PreToolUse | ✅ | ✅ |
| PostToolUse | ✅ | ✅ |
| UserPromptSubmit | ✅ | ✅ |
| Stop | ✅ | ✅ |
| SubagentStop | ✅ | ✅ |
| PreCompact | ✅ | ✅ |
| SessionStart | ❌ | ✅ |
| SessionEnd | ❌ | ✅ |
| Notification | ❌ | ✅ |
| PermissionRequest | ❌* | ✅ |
*SDK uses can_use_tool callback instead of PermissionRequest hooks.
SDK Limitations
The SDK documentation states: "Due to setup limitations, the Python SDK does not support SessionStart, SessionEnd, and Notification hooks."
Developer Experience¶
SDK Hooks - Manual & Verbose¶
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient, HookMatcher
async def check_bash(input_data, tool_use_id, context):
# Manual tool name check (no routing)
if input_data["tool_name"] != "Bash":
return {}
# Manual dict access (no typed properties)
command = input_data["tool_input"].get("command", "")
if "rm -rf" in command:
# Manual response dict construction
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Dangerous command blocked",
}
}
return {}
# Manual registration
options = ClaudeAgentOptions(
hooks={
"PreToolUse": [
HookMatcher(matcher="Bash", hooks=[check_bash]),
],
}
)
async with ClaudeSDKClient(options=options) as client:
await client.query("Run rm -rf /")
async for msg in client.receive_response():
print(msg)
fasthooks - Concise & Typed¶
from fasthooks import HookApp, deny
app = HookApp()
@app.pre_tool("Bash") # Decorator routing
def check_bash(event):
# Typed property access
if "rm -rf" in event.command:
return deny("Dangerous command blocked") # Helper function
if __name__ == "__main__":
app.run()
Key DX differences:
| Aspect | SDK | fasthooks |
|---|---|---|
| Routing | Manual if tool_name check |
@app.pre_tool("Bash") decorator |
| Event access | input_data["tool_input"]["command"] |
event.command |
| Response | Raw dict with nested structure | deny("reason") |
| Registration | Dict in options | Decorator |
Feature Matrix¶
| Feature | SDK | fasthooks |
|---|---|---|
| Typed events | TypedDict | Pydantic + properties |
| Response helpers | ❌ | allow(), deny(), block() |
| Tool matchers | String only | Decorators + when= guards |
| Multiple tools | "Write\|Edit" regex |
@app.pre_tool("Write", "Edit") |
| Catch-all | matcher=None |
@app.pre_tool() or "*" |
| State persistence | ❌ | State dependency |
| Transcript parsing | ❌ | Transcript dependency |
| Background tasks | ❌ | Tasks dependency |
| Blueprints | ❌ | Blueprint class |
| Middleware | ❌ | @app.middleware |
| Testing utils | ❌ | MockEvent, TestClient |
Statefulness¶
SDK Hooks - Stateless¶
SDK hooks have no built-in state management. Each callback is stateless:
# SDK: No state between calls
async def my_hook(input_data, tool_use_id, context):
# Must manually read transcript_path if you need history
transcript_path = input_data.get("transcript_path")
# Must implement your own persistence
return {}
fasthooks - Built-in State & Transcript¶
from fasthooks.depends import State, Transcript
@app.pre_tool("Bash")
def rate_limit(event, state: State, transcript: Transcript):
# state: persisted dict (JSON file per session)
count = state.get("bash_count", 0) + 1
state["bash_count"] = count
state.save()
# transcript: parsed history with aggregated stats
stats = transcript.stats
if stats.tool_calls.get("Bash", 0) > 100:
return deny(f"Rate limit: {stats.tool_calls['Bash']} bash commands")
# Access token usage, duration, file counts, etc.
print(f"Session tokens: {stats.total_tokens}")
print(f"Duration: {stats.duration_seconds}s")
Background Tasks¶
SDK hooks have no background task support. fasthooks provides async work that feeds back in subsequent hooks:
from fasthooks.tasks import task, Tasks
@task
def analyze_code(code: str) -> str:
# Runs in thread pool, non-blocking
return expensive_analysis(code)
@app.pre_tool("Write")
def on_write(event, tasks: Tasks):
tasks.add(analyze_code, event.content) # Fire and forget
return allow()
@app.on_prompt()
def check_analysis(event, tasks: Tasks):
if result := tasks.pop(analyze_code): # Check if done
return allow(message=f"Analysis: {result}")
return allow()
When to Use Each¶
Use SDK Hooks When:¶
- Building an application that embeds Claude via the SDK
- You need in-process callbacks (no subprocess overhead)
- You're already using
ClaudeSDKClientfor custom tools - You want hooks and custom MCP tools in the same process
# SDK: Hooks + custom tools in one application
options = ClaudeAgentOptions(
mcp_servers={"tools": my_sdk_mcp_server},
hooks={"PreToolUse": [HookMatcher(matcher="Bash", hooks=[my_hook])]},
)
async with ClaudeSDKClient(options=options) as client:
# Interactive conversation with hooks
await client.query("...")
Use fasthooks When:¶
- You're a CLI user customizing Claude Code behavior
- You need persistent state across hook invocations
- You need transcript analysis (token counts, tool usage stats)
- You need background async work (API calls, Claude sub-agents)
- You need SessionStart, SessionEnd, or Notification events
- You're building reusable hook libraries
- You want FastAPI-like DX with dependency injection
# fasthooks: Standalone hook with full features
from fasthooks import HookApp, allow, deny
from fasthooks.depends import State, Transcript
from fasthooks.tasks import Tasks
app = HookApp(state_dir="/tmp/hooks-state")
@app.pre_tool("Bash")
def check(event, state: State, transcript: Transcript, tasks: Tasks):
# Full access to state, history, and background tasks
...
if __name__ == "__main__":
app.run()
Migration Path¶
If you're using SDK hooks and want fasthooks features:
Option 1: Use fasthooks for CLI hooks, SDK for embedded apps
They serve different use cases and can coexist.
Option 2: Call fasthooks from SDK hooks
For complex logic, you could spawn fasthooks as a subprocess from SDK hooks, though this adds overhead.
Option 3: Use fasthooks' Claude sub-agent integration
fasthooks can spawn Claude sub-agents via the SDK for AI-powered background tasks:
from fasthooks.contrib.claude import agent_task, ClaudeAgent
from fasthooks.tasks import Tasks
@agent_task(model="haiku", system_prompt="Review code for bugs.")
async def review_code(agent: ClaudeAgent, code: str) -> str:
return await agent.query(f"Review:\n{code}")
@app.pre_tool("Write")
def on_write(event, tasks: Tasks):
tasks.add(review_code, event.content)
return allow()
Summary¶
| Aspect | SDK Hooks | fasthooks |
|---|---|---|
| Philosophy | Minimal, in-process | Batteries-included framework |
| Best for | SDK applications | CLI hook development |
| DX | Manual, verbose | FastAPI-like, concise |
| State | DIY | Built-in |
| Events | 6 types | 9+ types |
| Ecosystem | Part of SDK | Standalone library |