Org Status: π’ Active Cloudflare: N/A Last Audited: 2026-04-28
Claude Code is a terminal-native AI coding agent. Out of the box, it reads your codebase, writes files, and runs commands. But the real power comes from extending it β hooks that intercept every tool call, plugins that package reusable workflows, MCP servers that connect it to external systems, and companion tools that monitor what itβs doing. This article is a comprehensive guide to every extension point Claude Code exposes, with production-ready code you can use today.
What youβll learn:
- Every hook lifecycle event (all 24 of them) and how to configure them
- How to build custom MCP servers that give Claude new capabilities
- The plugin system: skills, agents, hooks, and LSP servers in a single distributable package
- How to build a real-time activity monitor using
--output-format stream-json - Patterns for companion tools: notch HUDs, cost dashboards, session managers
- How channels turn Claude Code into an event-driven agent that reacts to Telegram, Discord, and webhooks
- What the ecosystem looks like in 2026 and where itβs heading
Claude Code is powerful out of the box, but every team eventually hits the same walls:
No guardrails. Claude can run rm -rf /, push to main, or write files that donβt pass your linter. You need a way to intercept dangerous actions before they happen β not after.
No integration. Your workflow involves Jira, Slack, a custom database, internal APIs. Claude canβt reach any of them without you copy-pasting context back and forth.
No visibility. When Claude is working on a long task, you have no idea what itβs doing, how much itβs spending, or whether itβs stuck. Youβre staring at a terminal hoping for the best.
No reuse. Youβve crafted the perfect set of skills and hooks for your teamβs workflow. Now you need to share them across 15 repos and 8 developers without everyone maintaining their own copy.
No reactivity. Claude waits for you to type. It canβt react to a CI failure, a Slack message, or a monitoring alert while youβre away.
Every one of these problems has a solution in Claude Codeβs extension system. The challenge is knowing which extension point to use and how to wire it up correctly.
Extension Points Overview
Claude Code exposes five distinct extension mechanisms, each operating at a different layer:
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β Claude Code CLI β
β β
β ββββββββββββ ββββββββββββ βββββββββββββββββ β
β β Hooks β β Plugins β β MCP Servers β β
β β (events) β β(packages)β β (tools/data) β β
β ββββββββββββ ββββββββββββ βββββββββββββββββ β
β β
β ββββββββββββ ββββββββββββββββββββββββββββββββ β
β β Channels β β Headless / Stream-JSON API β β
β β (push) β β (programmatic consumption) β β
β ββββββββββββ ββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
| Extension Point | What It Does | When To Use It |
|---|---|---|
| Hooks | Intercept lifecycle events (tool calls, session start/stop, file changes) | Guardrails, formatting, logging, notifications |
| Plugins | Package skills + agents + hooks + MCP servers into a distributable unit | Sharing workflows across repos and teams |
| MCP Servers | Add custom tools and data sources via the Model Context Protocol | Connecting to databases, APIs, external services |
| Channels | Push external events into a running session | Chat bridges, webhooks, CI notifications |
| Headless API | Run Claude Code programmatically with structured output | CI/CD, orchestration, monitoring dashboards |
Key insight: Hooks are about control (blocking, modifying, observing). MCP servers are about capability (new tools). Plugins are about packaging (distributing a bundle of hooks + skills + MCP configs). Channels are about reactivity (pushing events in). The headless API is about integration (consuming Claudeβs output programmatically).
Configuration Hierarchy
Claude Codeβs settings cascade through multiple layers, with later layers overriding earlier ones:
// The effective configuration is resolved in this order:
interface ConfigurationLayers {
// 1. User-wide settings (lowest priority)
userSettings: "~/.claude/settings.json";
// 2. Project settings (shared via git)
projectSettings: ".claude/settings.json";
// 3. Local project settings (gitignored)
localSettings: ".claude/settings.local.json";
// 4. Plugin settings (when plugin is enabled)
pluginSettings: "<plugin-root>/settings.json";
// 5. Plugin hooks
pluginHooks: "<plugin-root>/hooks/hooks.json";
// 6. Skill/Agent frontmatter (while component is active)
skillFrontmatter: "<skill>/SKILL.md frontmatter";
// 7. Managed policy settings (highest priority, admin-controlled)
policySettings: "organization-managed";
}
Every extension mechanism reads from these same files. Understanding the cascade is essential before you configure anything.
The Settings File
The primary settings file is JSON. Hereβs a real production configuration showing hooks, permissions, and plugins working together:
{
"permissions": {
"allow": [
"WebFetch",
"WebSearch",
"Bash(git:*)",
"Bash(gh:*)",
"Bash(npm:*)",
"Bash(pnpm:*)",
"Bash(npx:*)",
"Bash(node:*)",
"Bash(wrangler:*)",
"Bash(curl:*)",
"Bash(jq:*)",
"Bash(ls:*)",
"Bash(cat:*)",
"Bash(mkdir:*)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/block-destructive.sh"
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/cost-check.sh"
}
]
}
]
},
"enabledPlugins": {
"typescript-lsp@claude-plugins-official": true,
"frontend-design@claude-plugins-official": true
},
"alwaysThinkingEnabled": true
}
Key insight: The
permissions.allowlist uses a glob syntax:"Bash(git:*)"allows any bash command starting withgit. This is your first line of defense β whitelist safe commands, and everything else requires approval.
Hooks are the most powerful extension point in Claude Code. They let you run shell commands, HTTP requests, LLM prompts, or subagent evaluations at 24 different lifecycle events. Every hook can observe, modify, or block the action that triggered it.
Hook Architecture
User types prompt
β
βΌ
UserPromptSubmit ββββ Can block the prompt entirely
β
βΌ
Claude processes ββββΊ PreToolUse ββββ Can block/modify tool input
β β
β βΌ
β Tool executes
β β
β βΌ
β PostToolUse ββββ Can give feedback (tool already ran)
β PostToolUseFailure βββ If tool failed
β
βΌ
Claude finishes
β
βΌ
Stop βββββββββββ Can prevent Claude from stopping
Hook Configuration Schema
Every hook entry follows this structure:
interface HookConfiguration {
hooks: {
[EventName: string]: MatcherGroup[];
};
}
interface MatcherGroup {
// Regex pattern to match against (tool name, notification type, etc.)
// Use "*" or omit for "match everything"
matcher?: string;
// Array of hook handlers to run when matched
hooks: HookHandler[];
}
type HookHandler =
| CommandHook
| HttpHook
| PromptHook
| AgentHook;
interface CommandHook {
type: "command";
command: string; // Shell command to execute
async?: boolean; // Run in background without blocking
shell?: "bash" | "powershell";
timeout?: number; // Seconds (default: 600)
statusMessage?: string; // Custom spinner text
if?: string; // Permission rule filter, e.g. "Bash(git *)"
once?: boolean; // Run only once per session (skills only)
}
interface HttpHook {
type: "http";
url: string; // POST endpoint
headers?: Record<string, string>; // Supports $VAR interpolation
allowedEnvVars?: string[];
timeout?: number; // Seconds (default: 30)
}
interface PromptHook {
type: "prompt";
prompt: string; // $ARGUMENTS for hook input JSON
model?: string; // e.g. "claude-sonnet-4-6-20250514"
timeout?: number; // Seconds (default: 30)
}
interface AgentHook {
type: "agent";
prompt: string; // Agent prompt
model?: string;
timeout?: number; // Seconds (default: 60)
// Agent can use Read, Grep, Glob tools to verify conditions
}
Exit Code Semantics
Every command hook communicates its result through exit codes:
| Exit Code | Meaning | Effect |
|---|---|---|
0 | Success | Proceed normally; parse stdout as JSON for output fields |
2 | Block | Block the action; stderr is fed to Claude as feedback |
| Any other | Non-blocking error | Continue; stderr shown in verbose mode only |
JSON Output Schema
Command hooks can return JSON on stdout to control behavior:
interface HookOutput {
// Control flow
continue?: boolean; // false = stop the session
stopReason?: string; // Message when continue is false
// Feedback
suppressOutput?: boolean; // Hide hook output from user
systemMessage?: string; // Warning shown to user
decision?: "block" | "allow" | "deny";
reason?: string; // Explanation of decision
additionalContext?: string; // Extra context for Claude
// Event-specific output
hookSpecificOutput?: {
hookEventName: string;
// PreToolUse / PermissionRequest
permissionDecision?: "allow" | "deny" | "ask";
permissionDecisionReason?: string;
updatedInput?: Record<string, unknown>; // Modify tool input
// PostToolUse (MCP tools only)
updatedMCPToolOutput?: string;
// SessionStart, CwdChanged, FileChanged
additionalContext?: string;
// WorktreeCreate
worktreePath?: string;
};
}
All 24 Lifecycle Events
Here is the complete reference for every hook event Claude Code supports:
Session Lifecycle
SessionStart
Fires when a session begins or resumes. Use it to inject context, set environment variables, or run setup scripts.
{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/session-setup.sh",
"statusMessage": "Setting up session"
}
]
}
]
}
}
Matcher values: startup, resume, clear, compact
Special feature: Write to $CLAUDE_ENV_FILE to persist environment variables for bash commands in the session:
#!/usr/bin/env bash
echo "export NODE_ENV=development" >> "$CLAUDE_ENV_FILE"
echo "export DATABASE_URL=postgresql://localhost/mydb" >> "$CLAUDE_ENV_FILE"
COMMITS_TODAY=$(git log --oneline --since="midnight" | wc -l | tr -d ' ')
BRANCH=$(git branch --show-current)
jq -n \
--arg commits "$COMMITS_TODAY" \
--arg branch "$BRANCH" \
'{
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: "Branch: \($branch). Commits today: \($commits)."
}
}'
SessionEnd
Fires when the session terminates.
Matcher values: clear, resume, logout, prompt_input_exit, bypass_permissions_disabled, other
{
"hooks": {
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/cleanup.sh",
"async": true
}
]
}
]
}
}
UserPromptSubmit
Fires when the user submits a prompt, before Claude processes it. Can block the prompt entirely (exit 2).
#!/usr/bin/env bash
INPUT=$(cat -)
PROMPT=$(echo "$INPUT" | jq -r '.prompt // ""')
if echo "$PROMPT" | grep -qiE 'production|prod db|prod database'; then
echo "Blocked: prompt mentions production. Use staging instead." >&2
exit 2
fi
exit 0
Tool Lifecycle
PreToolUse
The most important hook. Fires before any tool call executes. Can approve, deny, or modify the tool input.
Matcher values: Tool names β Bash, Edit, Write, Read, Glob, Grep, Agent, WebFetch, WebSearch, AskUserQuestion, mcp__*
#!/usr/bin/env bash
INPUT=$(cat -)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
if echo "$COMMAND" | grep -qE 'git push.*(--force|-f )|git reset --hard'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Destructive git command blocked by policy"
}
}'
exit 0
fi
if [[ "$TOOL_NAME" == "Write" || "$TOOL_NAME" == "Edit" ]]; then
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
if echo "$FILE_PATH" | grep -qE '\.(env|pem|key)$|/credentials'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Cannot write to secrets/credential files"
}
}'
exit 0
fi
fi
exit 0
Modifying tool input (available since v2.0.10):
#!/usr/bin/env bash
INPUT=$(cat -)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
if echo "$COMMAND" | grep -qE '^(wrangler|terraform|kubectl) (deploy|apply)' && \
! echo "$COMMAND" | grep -q '\-\-dry-run'; then
MODIFIED="${COMMAND} --dry-run"
jq -n --arg cmd "$MODIFIED" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "allow",
permissionDecisionReason: "Added --dry-run for safety",
updatedInput: { command: $cmd }
}
}'
exit 0
fi
exit 0
PostToolUse
Fires after a tool executes successfully. The tool has already run β you canβt undo it, but you can give Claude feedback or run follow-up actions.
Matcher values: Same tool names as PreToolUse
#!/usr/bin/env bash
INPUT=$(cat -)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
if [[ "$TOOL_NAME" == "Write" || "$TOOL_NAME" == "Edit" ]]; then
EXT="${FILE_PATH##*.}"
case "$EXT" in
ts|tsx|js|jsx)
npx prettier --write "$FILE_PATH" 2>/dev/null
npx eslint --fix "$FILE_PATH" 2>/dev/null
;;
py)
black -q "$FILE_PATH" 2>/dev/null
;;
sh|bash)
shfmt -i 2 -w "$FILE_PATH" 2>/dev/null
;;
esac
fi
exit 0
PostToolUseFailure
Fires when a tool execution fails. Not blockable, but you can inject context to help Claude recover.
#!/usr/bin/env bash
INPUT=$(cat -)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
ERROR=$(echo "$INPUT" | jq -r '.error // ""')
if echo "$COMMAND" | grep -q 'npm test'; then
# Read the last test output for context
COVERAGE=""
if [[ -f coverage/coverage-summary.json ]]; then
COVERAGE=$(jq -r '.total.lines.pct' coverage/coverage-summary.json 2>/dev/null)
fi
jq -n --arg cov "$COVERAGE" '{
hookSpecificOutput: {
hookEventName: "PostToolUseFailure",
additionalContext: "Line coverage: \($cov)%. Check test output for specific failures."
}
}'
fi
exit 0
PermissionRequest
Fires when the permission dialog is about to appear. You can auto-approve, auto-deny, or modify the permission suggestions.
#!/usr/bin/env bash
INPUT=$(cat -)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
if echo "$COMMAND" | grep -qE '^git (log|status|diff|show|branch)'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PermissionRequest",
decision: {
behavior: "allow"
}
}
}'
exit 0
fi
exit 0
Response Lifecycle
Stop
Fires when Claude finishes responding. Blockable β exit 2 or return decision: "block" to prevent Claude from stopping and force it to continue.
#!/usr/bin/env bash
INPUT=$(cat -)
LAST_MSG=$(echo "$INPUT" | jq -r '.last_assistant_message // ""')
STOP_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false')
if [[ "$STOP_ACTIVE" == "true" ]]; then
exit 0
fi
if ! echo "$LAST_MSG" | grep -qiE 'test.*pass|all tests|vitest.*pass'; then
jq -n '{
decision: "block",
reason: "Please run the test suite before finishing. Use: npm test"
}'
exit 0
fi
exit 0
StopFailure
Fires when a turn ends due to an API error (rate limit, auth failure, billing).
Matcher values: rate_limit, authentication_failed, billing_error, invalid_request, server_error, max_output_tokens, unknown
#!/usr/bin/env bash
INPUT=$(cat -)
ERROR=$(echo "$INPUT" | jq -r '.error // ""')
if [[ "$ERROR" == "rate_limit" ]]; then
curl -sf -X POST "$SLACK_WEBHOOK_URL" \
-H 'Content-Type: application/json' \
-d "{\"text\":\"Claude Code hit rate limit in $(basename $PWD)\"}" \
2>/dev/null &
fi
exit 0
Agent Lifecycle
SubagentStart
Fires when a subagent is spawned via the Agent tool.
Matcher values: Agent type names (Bash, Explore, Plan, custom agent names)
#!/usr/bin/env bash
INPUT=$(cat -)
AGENT_TYPE=$(echo "$INPUT" | jq -r '.agent_type // ""')
jq -n --arg type "$AGENT_TYPE" '{
hookSpecificOutput: {
hookEventName: "SubagentStart",
additionalContext: "Project uses TypeScript strict mode, Biome for linting, Vitest for tests. All files must end with newline."
}
}'
SubagentStop
Fires when a subagent finishes. Blockable β you can force the subagent to continue.
#!/usr/bin/env bash
INPUT=$(cat -)
LAST_MSG=$(echo "$INPUT" | jq -r '.last_assistant_message // ""')
STOP_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false')
if [[ "$STOP_ACTIVE" == "true" ]]; then
exit 0
fi
if ! echo "$LAST_MSG" | grep -qiE 'error handling|try.catch|catch|exception'; then
jq -n '{
decision: "block",
reason: "Please ensure error handling is included in all code changes."
}'
exit 0
fi
exit 0
Task Lifecycle (Agent Teams)
TaskCreated
Fires when a task is created via TaskCreate in agent teams.
#!/usr/bin/env bash
INPUT=$(cat -)
SUBJECT=$(echo "$INPUT" | jq -r '.task_subject // ""')
if [[ ! "$SUBJECT" =~ ^\[[A-Z]+-[0-9]+\] ]]; then
echo "Task must start with ticket reference like [ENG-123]" >&2
exit 2
fi
exit 0
TaskCompleted
Fires when a task is marked complete. Can block completion to enforce quality gates.
TeammateIdle
Fires when an agent team teammate is about to go idle. Blockable β you can force the teammate to keep working.
#!/usr/bin/env bash
if [[ ! -f "./dist/index.js" ]]; then
echo "Build artifact missing. Run build before stopping." >&2
exit 2
fi
exit 0
File and Config Lifecycle
InstructionsLoaded
Fires when CLAUDE.md or .claude/rules/*.md files are loaded.
Matcher values: session_start, nested_traversal, path_glob_match, include, compact
ConfigChange
Fires when a settings or skill file changes during a session.
Matcher values: user_settings, project_settings, local_settings, policy_settings, skills
#!/usr/bin/env bash
INPUT=$(cat -)
SOURCE=$(echo "$INPUT" | jq -r '.source // ""')
FILE_PATH=$(echo "$INPUT" | jq -r '.file_path // ""')
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) CONFIG_CHANGE source=$SOURCE path=$FILE_PATH" \
>> ~/.claude/audit.log
if [[ "$SOURCE" == "project_settings" ]]; then
jq -n '{
decision: "block",
reason: "Project settings changes require admin approval"
}'
exit 0
fi
exit 0
CwdChanged
Fires when the working directory changes. Supports $CLAUDE_ENV_FILE for env var injection.
#!/usr/bin/env bash
INPUT=$(cat -)
NEW_CWD=$(echo "$INPUT" | jq -r '.cwd // ""')
if [[ -f "$NEW_CWD/.env" ]]; then
while IFS= read -r line; do
[[ "$line" =~ ^#.*$ || -z "$line" ]] && continue
echo "export $line" >> "$CLAUDE_ENV_FILE"
done < "$NEW_CWD/.env"
fi
exit 0
FileChanged
Fires when a watched file changes on disk.
Matcher values: Filename/basename (e.g., .env, .envrc, package.json)
#!/usr/bin/env bash
INPUT=$(cat -)
FILE_PATH=$(echo "$INPUT" | jq -r '.file_path // ""')
BASENAME=$(basename "$FILE_PATH")
if [[ "$BASENAME" == "package.json" ]]; then
npm install --silent 2>/dev/null &
fi
exit 0
Compaction Lifecycle
PreCompact
Fires before context compaction (when the context window is being summarized to free space).
Matcher values: manual, auto
{
"hooks": {
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "echo '{\"systemMessage\":\"Carry forward: current branch, open files, failing tests.\"}'",
"statusMessage": "Preparing compaction checkpoint"
}
]
}
]
}
}
PostCompact
Fires after context compaction completes.
Worktree Lifecycle
WorktreeCreate
Fires when a git worktree is being created (used by agent teams). Blockable. Can return a custom worktree path.
WorktreeRemove
Fires when a worktree is being removed.
Notification Lifecycle
Notification
Fires when Claude Code sends any notification.
Matcher values: permission_prompt, idle_prompt, auth_success, elicitation_dialog
#!/usr/bin/env bash
INPUT=$(cat -)
MESSAGE=$(echo "$INPUT" | jq -r '.message // ""')
TYPE=$(echo "$INPUT" | jq -r '.notification_type // ""')
osascript -e "display notification \"$MESSAGE\" with title \"Claude Code: $TYPE\""
if [[ "$TYPE" == "permission_prompt" ]]; then
afplay /System/Library/Sounds/Ping.aiff 2>/dev/null &
fi
exit 0
MCP Lifecycle
Elicitation
Fires when an MCP server requests user input during tool execution. Can auto-fill form values.
ElicitationResult
Fires after the user responds to an MCP elicitation. Can block or modify the response before itβs sent to the server.
Hook Blockability Reference
| Hook Event | Blockable? | Effect of Block (exit 2) |
|---|---|---|
PreToolUse | Yes | Blocks tool call |
PermissionRequest | Yes | Denies permission |
UserPromptSubmit | Yes | Blocks prompt, erases it |
Stop | Yes | Prevents stopping, Claude continues |
SubagentStop | Yes | Prevents subagent from stopping |
TeammateIdle | Yes | Prevents idle, gives feedback |
TaskCreated | Yes | Prevents task creation |
TaskCompleted | Yes | Prevents task completion |
ConfigChange | Yes | Blocks config change (except policy) |
Elicitation | Yes | Denies elicitation |
ElicitationResult | Yes | Blocks response (becomes decline) |
WorktreeCreate | Yes | Fails worktree creation |
PostToolUse | Soft | Tool already ran; stderr fed to Claude |
PostToolUseFailure | No | Shows stderr to Claude |
Notification | No | Shows stderr to user only |
SubagentStart | No | Shows stderr to user only |
SessionStart | No | Shows stderr to user only |
SessionEnd | No | Shows stderr to user only |
InstructionsLoaded | No | Informational only |
CwdChanged | No | Informational only |
FileChanged | No | Informational only |
StopFailure | No | Output and exit code ignored |
PreCompact | No | Informational only |
PostCompact | No | Informational only |
Pattern 1: The Guardrail Stack
A layered defense system using PreToolUse hooks that prevents dangerous operations while staying out of the way for safe ones.
When to use: Every production Claude Code installation. This is your seatbelt.
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat -)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
TOOL_INPUT=$(echo "$INPUT" | jq -r '.tool_input')
deny() {
jq -n --arg reason "$1" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: $reason
}
}'
exit 0
}
allow() {
jq -n --arg reason "$1" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "allow",
permissionDecisionReason: $reason
}
}'
exit 0
}
if [[ "$TOOL_NAME" == "Bash" ]]; then
COMMAND=$(echo "$TOOL_INPUT" | jq -r '.command // ""')
# Never allow force push
if echo "$COMMAND" | grep -qE 'git push.*(--force|-f )'; then
deny "Force push blocked. Use --force-with-lease if necessary."
fi
# Never allow hard reset
if echo "$COMMAND" | grep -qE 'git reset --hard'; then
deny "Hard reset blocked. Use git stash or create a backup branch."
fi
# Never allow recursive delete of home or root
if echo "$COMMAND" | grep -qE 'rm -rf\s+(~|/|/home|/Users)'; then
deny "Recursive delete of system directories blocked."
fi
# Never allow dropping databases
if echo "$COMMAND" | grep -qiE 'drop\s+(database|table|schema)'; then
deny "DROP statements blocked. Use migrations."
fi
# Never skip git hooks
if echo "$COMMAND" | grep -qE '\-\-no-verify'; then
deny "Skipping git hooks is not allowed."
fi
fi
if [[ "$TOOL_NAME" == "Write" || "$TOOL_NAME" == "Edit" || "$TOOL_NAME" == "MultiEdit" ]]; then
FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // ""')
# Protect secrets and credentials
if echo "$FILE_PATH" | grep -qE '\.(env|pem|key|p12|pfx)$'; then
deny "Cannot modify secret/credential files."
fi
if echo "$FILE_PATH" | grep -qiE 'credentials|secrets|tokens'; then
deny "Cannot modify files in credentials/secrets directories."
fi
# Protect lock files
if echo "$FILE_PATH" | grep -qE '(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|Cargo\.lock)$'; then
deny "Lock files should not be edited directly. Use your package manager."
fi
fi
if [[ "$TOOL_NAME" == "Bash" ]]; then
COMMAND=$(echo "$TOOL_INPUT" | jq -r '.command // ""')
# Auto-approve safe read-only commands
if echo "$COMMAND" | grep -qE '^(git (log|status|diff|show|branch|remote)|ls|cat|head|tail|wc|find|grep|rg|which|echo|pwd)'; then
allow "Read-only command auto-approved"
fi
# Auto-approve test and lint commands
if echo "$COMMAND" | grep -qE '^(npm (test|run (lint|type-check|build))|pnpm (test|lint)|vitest|jest|biome|eslint|prettier)'; then
allow "Test/lint command auto-approved"
fi
fi
exit 0
Configuration:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/guardrails.sh"
}
]
}
]
}
}
Gotcha: The
iffield on hook handlers uses permission rule syntax ("Bash(git *)") and is evaluated before the command runs. If you need to inspect the actual command content (e.g., checking for--forceburied in a longer command), you must read stdin in the script. Theiffield is a coarse filter; the script does the fine-grained work.
Pattern 2: The Auto-Formatter Pipeline
Run formatters and linters after every file write, so Claudeβs output always matches your projectβs style.
When to use: Any project with a formatter (Prettier, Black, Biome, gofmt).
#!/usr/bin/env bash
INPUT=$(cat -)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
if [[ "$TOOL_NAME" != "Write" && "$TOOL_NAME" != "Edit" && "$TOOL_NAME" != "MultiEdit" ]]; then
exit 0
fi
[[ -z "$FILE_PATH" || ! -f "$FILE_PATH" ]] && exit 0
EXT="${FILE_PATH##*.}"
format_with_biome() {
if command -v biome &>/dev/null; then
biome check --write --unsafe "$FILE_PATH" 2>/dev/null
return $?
fi
return 1
}
format_with_prettier() {
if command -v prettier &>/dev/null; then
prettier --write "$FILE_PATH" 2>/dev/null
return $?
fi
# Try npx as fallback
if command -v npx &>/dev/null && [[ -f "node_modules/.bin/prettier" ]]; then
npx prettier --write "$FILE_PATH" 2>/dev/null
return $?
fi
return 1
}
case "$EXT" in
ts|tsx|js|jsx|json|css|scss|html|md)
# Prefer Biome for TS/JS, fall back to Prettier
if [[ "$EXT" =~ ^(ts|tsx|js|jsx|json|css)$ ]]; then
format_with_biome || format_with_prettier
else
format_with_prettier
fi
;;
py)
if command -v ruff &>/dev/null; then
ruff format "$FILE_PATH" 2>/dev/null
ruff check --fix "$FILE_PATH" 2>/dev/null
elif command -v black &>/dev/null; then
black -q "$FILE_PATH" 2>/dev/null
fi
;;
go)
if command -v gofmt &>/dev/null; then
gofmt -w "$FILE_PATH" 2>/dev/null
fi
if command -v goimports &>/dev/null; then
goimports -w "$FILE_PATH" 2>/dev/null
fi
;;
rs)
if command -v rustfmt &>/dev/null; then
rustfmt "$FILE_PATH" 2>/dev/null
fi
;;
sh|bash)
if command -v shfmt &>/dev/null; then
shfmt -i 2 -w "$FILE_PATH" 2>/dev/null
fi
;;
esac
exit 0
Configuration:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/auto-format.sh"
}
]
}
]
}
}
Connection to Pattern 1: The guardrail stack blocks dangerous writes; the auto-formatter cleans up the ones that get through. Together, they form a complete write pipeline: validate then execute then format.
Pattern 3: The Session Context Injector
Inject rich context at session start so Claude knows about your projectβs current state without you having to explain it.
When to use: Any project where Claude needs to know about recent changes, open issues, or deployment status.
#!/usr/bin/env bash
CONTEXT_PARTS=()
BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
COMMITS_TODAY=$(git log --oneline --since="midnight" 2>/dev/null | wc -l | tr -d ' ')
LAST_COMMIT=$(git log -1 --pretty=format:"%h %s" 2>/dev/null || echo "no commits")
UNCOMMITTED=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
CONTEXT_PARTS+=("Git: branch=$BRANCH, commits_today=$COMMITS_TODAY, uncommitted_files=$UNCOMMITTED, last_commit=\"$LAST_COMMIT\"")
if command -v gh &>/dev/null; then
OPEN_PRS=$(gh pr list --state open --limit 5 --json number,title \
--jq '.[] | "#\(.number) \(.title)"' 2>/dev/null | head -5)
if [[ -n "$OPEN_PRS" ]]; then
CONTEXT_PARTS+=("Open PRs: $OPEN_PRS")
fi
fi
if [[ -f "package.json" ]]; then
NAME=$(jq -r '.name // "unknown"' package.json)
SCRIPTS=$(jq -r '.scripts | keys | join(", ")' package.json 2>/dev/null)
CONTEXT_PARTS+=("Project: $NAME. Available scripts: $SCRIPTS")
fi
if command -v tsc &>/dev/null && [[ -f "tsconfig.json" ]]; then
TS_ERRORS=$(tsc --noEmit 2>&1 | grep -c "error TS" || echo "0")
if [[ "$TS_ERRORS" -gt 0 ]]; then
CONTEXT_PARTS+=("TypeScript: $TS_ERRORS errors")
fi
fi
FULL_CONTEXT=$(printf '%s\n' "${CONTEXT_PARTS[@]}")
jq -n --arg ctx "$FULL_CONTEXT" '{
hookSpecificOutput: {
hookEventName: "SessionStart",
additionalContext: $ctx
}
}'
Real-world example β API spend injection:
This pattern is used in production to show API costs at session start:
#!/usr/bin/env bash
APIMOM="https://apimom-router.apiservices.workers.dev"
TIMEOUT=4
result=$(curl -sf --max-time $TIMEOUT "$APIMOM/v1/costs?period=day" \
-H "X-Project-ID: $PROJECT_ID" \
-H "X-API-Key: $API_KEY" 2>/dev/null)
if [[ -z "$result" ]]; then exit 0; fi
spend=$(echo "$result" | python3 -c "
import sys, json
d = json.load(sys.stdin)
print(d.get('today_spend_usd', 0))
" 2>/dev/null)
limit=$(echo "$result" | python3 -c "
import sys, json
d = json.load(sys.stdin)
print(d.get('daily_limit_usd', 0))
" 2>/dev/null)
echo "API spend today: \$${spend:-0} / \$${limit:-0}"
if [[ -n "$spend" && -n "$limit" && "$limit" != "0" ]]; then
pct=$(python3 -c "print(round(float('$spend') / float('$limit') * 100))")
if (( pct >= 80 )); then
echo "WARNING: API budget at ${pct}%"
fi
fi
Pattern 4: The HTTP Webhook Bridge
Route hook events to a centralized HTTP endpoint for logging, analytics, or approval workflows.
When to use: Teams that need centralized policy enforcement, audit logs, or approval gates.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "http",
"url": "https://hooks.internal.company.com/claude-code/pre-tool-use",
"headers": {
"Authorization": "Bearer $CLAUDE_HOOK_TOKEN",
"X-Developer": "$USER"
},
"allowedEnvVars": ["CLAUDE_HOOK_TOKEN", "USER"],
"timeout": 5
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "http",
"url": "https://hooks.internal.company.com/claude-code/session-complete",
"headers": {
"Authorization": "Bearer $CLAUDE_HOOK_TOKEN"
},
"allowedEnvVars": ["CLAUDE_HOOK_TOKEN"],
"timeout": 10
}
]
}
]
}
}
Server-side handler (TypeScript + Hono):
import { Hono } from "hono";
interface PreToolUsePayload {
session_id: string;
cwd: string;
tool_name: string;
tool_input: {
command?: string;
file_path?: string;
content?: string;
};
}
const app = new Hono();
// Centralized policy enforcement
app.post("/claude-code/pre-tool-use", async (c) => {
const payload = await c.req.json<PreToolUsePayload>();
const developer = c.req.header("X-Developer") ?? "unknown";
// Log every tool call
console.log(JSON.stringify({
event: "tool_call",
developer,
session: payload.session_id,
tool: payload.tool_name,
timestamp: new Date().toISOString(),
}));
// Check against blocked commands list
if (payload.tool_name === "Bash" && payload.tool_input.command) {
const blocked = await isCommandBlocked(payload.tool_input.command);
if (blocked) {
return c.json({
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: `Command blocked by policy: ${blocked.reason}`,
},
});
}
}
// Default: allow
return c.json({});
});
// Session analytics
app.post("/claude-code/session-complete", async (c) => {
const payload = await c.req.json();
const developer = c.req.header("X-Developer") ?? "unknown";
await recordSessionMetrics({
developer,
session_id: payload.session_id,
completed_at: new Date().toISOString(),
});
return c.json({});
});
async function isCommandBlocked(
command: string
): Promise<{ reason: string } | null> {
// Check against your policy database
const policies = [
{ pattern: /docker.*--privileged/, reason: "Privileged containers not allowed" },
{ pattern: /curl.*\|\s*bash/, reason: "Pipe-to-bash not allowed" },
{ pattern: /chmod\s+777/, reason: "World-writable permissions not allowed" },
];
for (const policy of policies) {
if (policy.pattern.test(command)) {
return { reason: policy.reason };
}
}
return null;
}
async function recordSessionMetrics(metrics: Record<string, string>) {
// Send to your analytics pipeline
console.log("Session metrics:", metrics);
}
export default app;
Gotcha: HTTP hooks have a default timeout of 30 seconds. If your policy server is slow, Claude Code will proceed without waiting. Set
timeoutexplicitly and ensure your server responds quickly.
Pattern 5: The LLM-as-Judge Hook
Use a prompt or agent hook to evaluate Claudeβs actions using a separate LLM call β essentially an AI reviewing another AIβs work.
When to use: Complex policy decisions that canβt be expressed as regex patterns or shell logic.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "prompt",
"prompt": "You are a code review guard. Analyze this tool call and decide if it should proceed. The tool is about to write/edit a file. Check for: 1) Hardcoded secrets or API keys 2) SQL injection vulnerabilities 3) Console.log statements in production code 4) TODO/FIXME comments without ticket references. Input: $ARGUMENTS. Respond with JSON: {\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\"}} or deny with a reason.",
"model": "claude-haiku-4-5-20250514",
"timeout": 15
}
]
}
]
}
}
Agent hook for deeper analysis:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "agent",
"prompt": "Review the session transcript. Check that: 1) All new files have corresponding tests 2) No new dependencies were added without justification 3) TypeScript strict mode is maintained. Read the changed files using the tools available to you. If any check fails, block with a specific reason.",
"model": "claude-sonnet-4-6-20250514",
"timeout": 60
}
]
}
]
}
}
Key insight: Agent hooks can use Read, Grep, and Glob tools, making them much more powerful than prompt hooks. They can actually inspect the codebase to verify conditions. The trade-off is latency β agent hooks take 10-60 seconds, while prompt hooks take 2-15 seconds.
Pattern 6: The tmux Integration
Integrate Claude Code with tmux for window naming, session management, and multi-pane workflows.
When to use: Developers who run multiple Claude Code sessions in tmux.
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "tmux rename-window \"$(basename \"$PWD\")\"",
"async": true,
"statusMessage": "Setting up tmux"
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "tmux rename-window \"$(basename \"$PWD\")\"",
"async": true
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "tmux rename-window \"$(basename \"$PWD\") [done]\"",
"async": true
}
]
}
]
}
}
Connection to other patterns: The tmux integration pairs well with the notification hook. When Claude finishes or needs input, the tmux window name changes AND you get a macOS notification β so you know which window to switch to.
MCP (Model Context Protocol) is how you give Claude Code access to external tools and data sources. An MCP server exposes tools, resources, and prompts that Claude can call just like its built-in tools.
Adding MCP Servers
Three transport types, three commands:
claude mcp add --transport http notion https://mcp.notion.com/mcp
claude mcp add --transport sse asana https://mcp.asana.com/sse
claude mcp add --transport stdio --env GITHUB_TOKEN=ghp_xxx github \
-- npx -y @modelcontextprotocol/server-github
Scoping:
claude mcp add my-server -- npx my-server
claude mcp add --scope project my-server -- npx my-server
claude mcp add --scope user my-server -- npx my-server
Management:
claude mcp list # Show all configured servers
claude mcp get github # Show details for one server
claude mcp remove github # Remove a server
/mcp # Check status within Claude Code
Building a Custom MCP Server
Hereβs a complete MCP server that gives Claude access to a SQLite database:
// db-server.ts β MCP server for querying a local SQLite database
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import Database from "better-sqlite3";
import { z } from "zod";
const DB_PATH = process.env.DB_PATH ?? "./data.db";
const db = new Database(DB_PATH, { readonly: true });
const server = new McpServer({
name: "sqlite-query",
version: "1.0.0",
});
// Tool: Execute a read-only SQL query
server.tool(
"query",
"Execute a read-only SQL query against the database",
{
sql: z.string().describe("SQL SELECT query to execute"),
params: z.array(z.union([z.string(), z.number()])).optional()
.describe("Bind parameters for the query"),
},
async ({ sql, params }) => {
// Safety: only allow SELECT statements
const normalized = sql.trim().toUpperCase();
if (!normalized.startsWith("SELECT") && !normalized.startsWith("WITH")) {
return {
content: [{
type: "text",
text: "Error: Only SELECT queries are allowed. This server is read-only.",
}],
isError: true,
};
}
try {
const rows = db.prepare(sql).all(...(params ?? []));
return {
content: [{
type: "text",
text: JSON.stringify(rows, null, 2),
}],
};
} catch (error) {
return {
content: [{
type: "text",
text: `Query error: ${error instanceof Error ? error.message : String(error)}`,
}],
isError: true,
};
}
}
);
// Tool: List all tables and their schemas
server.tool(
"schema",
"List all tables and their column definitions",
{},
async () => {
const tables = db.prepare(
"SELECT name, sql FROM sqlite_master WHERE type='table' ORDER BY name"
).all() as Array<{ name: string; sql: string }>;
const schema = tables.map((t) => {
const columns = db.prepare(`PRAGMA table_info('${t.name}')`).all();
return {
table: t.name,
columns,
createStatement: t.sql,
};
});
return {
content: [{
type: "text",
text: JSON.stringify(schema, null, 2),
}],
};
}
);
// Resource: Database statistics
server.resource(
"stats",
"sqlite://stats",
async () => {
const tableCount = db.prepare(
"SELECT COUNT(*) as count FROM sqlite_master WHERE type='table'"
).get() as { count: number };
const tables = db.prepare(
"SELECT name FROM sqlite_master WHERE type='table'"
).all() as Array<{ name: string }>;
const stats = tables.map((t) => {
const count = db.prepare(
`SELECT COUNT(*) as count FROM "${t.name}"`
).get() as { count: number };
return { table: t.name, rows: count.count };
});
return {
contents: [{
uri: "sqlite://stats",
mimeType: "application/json",
text: JSON.stringify({
database: DB_PATH,
tables: tableCount.count,
tableStats: stats,
}, null, 2),
}],
};
}
);
// Start the server
const transport = new StdioServerTransport();
await server.connect(transport);
Install it:
claude mcp add --transport stdio --env DB_PATH=./myapp.db sqlite-query \
-- npx tsx db-server.ts
Now Claude can query your database:
You: "How many users signed up in the last 7 days?"
Claude: Let me check the database.
[Uses mcp__sqlite-query__schema to see tables]
[Uses mcp__sqlite-query__query with "SELECT COUNT(*) FROM users WHERE created_at > datetime('now', '-7 days')"]
MCP Server with Dynamic Tool Updates
MCP servers can dynamically change their available tools using list_changed notifications:
// feature-flags-server.ts β tools change based on feature flags
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "feature-flags",
version: "1.0.0",
});
// Base tool: check flags
server.tool(
"check-flag",
"Check if a feature flag is enabled",
{ flag: z.string() },
async ({ flag }) => {
const enabled = await checkFlag(flag);
return {
content: [{ type: "text", text: `Flag "${flag}" is ${enabled ? "enabled" : "disabled"}` }],
};
}
);
// Periodically check for new flags and register tools
setInterval(async () => {
const flags = await fetchAllFlags();
for (const flag of flags) {
if (flag.hasCustomAction) {
server.tool(
`toggle-${flag.name}`,
`Toggle the ${flag.name} feature flag`,
{ enabled: z.boolean() },
async ({ enabled }) => {
await setFlag(flag.name, enabled);
return {
content: [{
type: "text",
text: `Flag "${flag.name}" set to ${enabled}`,
}],
};
}
);
}
}
// Notify Claude Code that tools have changed
server.notification({ method: "notifications/tools/list_changed" });
}, 30_000);
async function checkFlag(name: string): Promise<boolean> {
// Your flag service implementation
return false;
}
async function fetchAllFlags(): Promise<Array<{ name: string; hasCustomAction: boolean }>> {
return [];
}
async function setFlag(name: string, value: boolean): Promise<void> {
// Your flag service implementation
}
const transport = new StdioServerTransport();
await server.connect(transport);
Project-Level MCP Configuration
Share MCP servers with your team via .mcp.json at the repo root:
{
"mcpServers": {
"project-db": {
"command": "npx",
"args": ["tsx", "./tools/db-server.ts"],
"env": {
"DB_PATH": "./data/dev.db"
}
},
"internal-api": {
"type": "http",
"url": "https://api.internal.company.com/mcp",
"headers": {
"Authorization": "Bearer ${INTERNAL_API_TOKEN}"
}
},
"design-system": {
"command": "npx",
"args": ["-y", "@company/design-system-mcp"],
"env": {
"FIGMA_TOKEN": "${FIGMA_TOKEN}"
}
}
}
}
Key insight: The
.mcp.jsonfile supports${VAR}syntax for environment variable interpolation. Never hardcode secrets β reference them through environment variables that each developer sets locally.
Plugins package skills, agents, hooks, MCP servers, and LSP servers into a single distributable unit. They solve the βI want to share this workflow with my teamβ problem.
Plugin Anatomy
my-plugin/
βββ .claude-plugin/
β βββ plugin.json # Required: metadata
βββ skills/ # Slash commands + AI-invoked skills
β βββ review/
β β βββ SKILL.md
β βββ deploy/
β βββ SKILL.md
β βββ deploy-checklist.sh
βββ agents/ # Custom subagent definitions
β βββ security-reviewer.md
βββ commands/ # Slash commands (legacy, same as skills)
β βββ quick-test.md
βββ hooks/
β βββ hooks.json # Hook definitions
βββ .mcp.json # MCP server configurations
βββ .lsp.json # LSP server configurations
βββ settings.json # Default settings when enabled
βββ README.md
Plugin Manifest
{
"name": "acme-dev-tools",
"description": "Development workflow tools for Acme Corp",
"version": "2.1.0",
"author": {
"name": "Acme Engineering"
},
"homepage": "https://github.com/acme/claude-code-plugin",
"repository": "https://github.com/acme/claude-code-plugin",
"license": "MIT"
}
The name field determines the namespace. Skills in this plugin become /acme-dev-tools:review, /acme-dev-tools:deploy, etc.
Skills
Skills are the primary way to extend Claudeβs command vocabulary. Each skill is a folder containing a SKILL.md with YAML frontmatter:
---
name: review
description: Perform a code review on the current branch, checking for security issues, performance problems, and style violations. Use when the user asks for a review or before creating a PR.
allowed-tools:
- Read
- Grep
- Glob
- Bash(git diff:*)
- Bash(git log:*)
---
Review the changes on the current branch compared to main.
1. Run `git diff main...HEAD --name-only` to get changed files
2. Read each changed file
3. Check for:
- Security issues (hardcoded secrets, SQL injection, XSS)
- Performance problems (N+1 queries, missing indexes, unbounded loops)
- Style violations (naming conventions, file organization)
- Missing error handling
- Missing or inadequate tests
4. Provide a structured review with severity levels
For each issue found:
- **File**: path/to/file.ts
- **Line**: approximate line number
- **Severity**: critical / warning / suggestion
- **Issue**: description
- **Fix**: recommended change
If $ARGUMENTS is provided, focus the review on that specific aspect.
Frontmatter fields:
| Field | Required | Description |
|---|---|---|
name | Yes | Skill identifier |
description | Yes | When Claude should use this skill (model-facing) |
allowed-tools | No | Restrict which tools this skill can use |
disabled-tools | No | Tools this skill cannot use |
disable-model-invocation | No | If true, only user can invoke (not Claude autonomously) |
user-invocable | No | If false, only Claude can invoke (background knowledge) |
model | No | Override the model for this skill |
context | No | Additional files/directories to load |
agent | No | Run as a subagent with isolated context |
Custom Agents
Agents are specialized Claude instances with their own system prompts, tool restrictions, and context:
---
name: security-reviewer
description: A security-focused code reviewer that only has read access
model: claude-sonnet-4-6-20250514
allowed-tools:
- Read
- Grep
- Glob
- Bash(git log:*)
- Bash(git diff:*)
- Bash(git show:*)
permission-mode: plan
---
You are a security-focused code reviewer. You have READ-ONLY access to the codebase.
Identify security vulnerabilities in code changes. Focus on:
1. **Authentication/Authorization**: Missing auth checks, privilege escalation
2. **Injection**: SQL injection, XSS, command injection, path traversal
3. **Data Exposure**: Secrets in code, PII leaks, verbose error messages
4. **Cryptography**: Weak algorithms, hardcoded keys, insecure random
5. **Dependencies**: Known CVEs, outdated packages
- NEVER suggest modifying files. You are read-only.
- Always cite specific file paths and line numbers.
- Rate each finding: CRITICAL, HIGH, MEDIUM, LOW
- Provide remediation guidance for each finding.
Plugin Hooks
Plugin hooks live in hooks/hooks.json and use the same schema as settings.json hooks:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | xargs npx biome check --write 2>/dev/null; exit 0"
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/validate-command.sh"
}
]
}
]
}
}
Note the ${CLAUDE_PLUGIN_ROOT} variable β it resolves to the pluginβs installation directory, so your hooks can reference scripts bundled with the plugin.
Plugin MCP Servers
Plugins can bundle MCP servers that start automatically when the plugin is enabled:
{
"mcpServers": {
"project-tools": {
"command": "${CLAUDE_PLUGIN_ROOT}/servers/project-tools",
"args": ["--config", "${CLAUDE_PLUGIN_ROOT}/config.json"],
"env": {
"DATA_DIR": "${CLAUDE_PLUGIN_DATA}"
}
}
}
}
${CLAUDE_PLUGIN_DATA} is a persistent data directory for the plugin β use it for caches, databases, or configuration that should survive across sessions.
Plugin LSP Servers
LSP (Language Server Protocol) plugins give Claude real-time code intelligence β jump to definition, find references, get diagnostics:
{
"go": {
"command": "gopls",
"args": ["serve"],
"extensionToLanguage": {
".go": "go"
}
}
}
Official LSP plugins exist for TypeScript, Python, and Rust. Create custom LSP plugins only for languages not already covered.
Installing and Managing Plugins
claude plugin install acme-dev-tools
claude --plugin-dir ./my-plugin
claude --plugin-dir ./plugin-one --plugin-dir ./plugin-two
/reload-plugins
/plugins
Plugin Settings
Plugins can ship default settings that activate when the plugin is enabled:
{
"agent": "security-reviewer"
}
This makes the security-reviewer agent the default for all sessions when this plugin is active β Claudeβs behavior changes to match the agentβs system prompt and tool restrictions.
Channels are MCP servers that push external events into a running Claude Code session. They invert the traditional model: instead of Claude calling tools, the outside world sends messages to Claude.
How Channels Work
External System βββΊ Channel Server βββΊ Claude Code Session
(Telegram) (MCP + push) (reacts to message)
(Discord)
(Webhook)
(CI/CD)
A channel declares the claude/channel capability, which lets it:
- Push notifications into the session
- Optionally expose a reply tool so Claude can respond
Built-in Channels
As of March 2026, three official channels ship with Claude Code:
| Channel | Direction | Use Case |
|---|---|---|
| Telegram | Two-way | Control Claude from your phone |
| Discord | Two-way | Team-based Claude interaction |
| iMessage | Two-way | Personal Claude assistant via text |
Enable channels at startup:
claude --channels telegram
claude --channels telegram,discord
Building a Custom Channel
A channel is an MCP server that declares claude/channel and pushes events:
// webhook-channel.ts β receive webhooks and push them into Claude's session
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import http from "node:http";
const server = new McpServer({
name: "webhook-channel",
version: "1.0.0",
capabilities: {
// Declare channel capability
"claude/channel": {},
},
});
// Expose a reply tool so Claude can respond
server.tool(
"reply",
"Send a response to the webhook source",
{
message: z.string().describe("Response message"),
webhook_id: z.string().describe("ID of the webhook to respond to"),
},
async ({ message, webhook_id }) => {
// Store the response for the webhook caller to retrieve
await storeResponse(webhook_id, message);
return {
content: [{ type: "text", text: `Response sent for webhook ${webhook_id}` }],
};
}
);
// Start an HTTP server to receive webhooks
const httpServer = http.createServer(async (req, res) => {
if (req.method === "POST" && req.url === "/webhook") {
const body = await readBody(req);
const payload = JSON.parse(body);
// Push the webhook event into Claude's session
server.notification({
method: "notifications/message",
params: {
content: {
type: "text",
text: `Webhook received from ${payload.source}: ${payload.message}`,
},
sender: {
name: payload.source ?? "webhook",
},
},
});
res.writeHead(200);
res.end(JSON.stringify({ status: "forwarded" }));
} else {
res.writeHead(404);
res.end();
}
});
httpServer.listen(3456, () => {
console.error("Webhook channel listening on port 3456");
});
function readBody(req: http.IncomingMessage): Promise<string> {
return new Promise((resolve) => {
let data = "";
req.on("data", (chunk) => data += chunk);
req.on("end", () => resolve(data));
});
}
async function storeResponse(id: string, message: string) {
// Implementation depends on your use case
}
const transport = new StdioServerTransport();
await server.connect(transport);
Key insight: Channels fundamentally change what Claude Code is. With channels, itβs not just a coding assistant you talk to β itβs an event-driven agent that reacts to the world while youβre away. A CI failure triggers Claude to investigate. A Slack message triggers Claude to respond. A monitoring alert triggers Claude to debug.
Claude Code can run without a terminal UI, outputting structured data for programmatic consumption.
Output Formats
claude -p "Explain this function" --output-format text
claude -p "Explain this function" --output-format json
claude -p "Explain this function" --output-format stream-json
JSON Output
The json format returns a single JSON object after completion:
interface ClaudeCodeJsonOutput {
result: string; // Claude's final response text
cost_usd: number; // Total cost of the session
duration_ms: number; // Wall-clock time
num_turns: number; // Number of agent turns
session_id: string; // Session identifier
is_error: boolean; // Whether the session ended in error
}
RESULT=$(claude -p "Generate a type for this schema" \
--output-format json \
--max-turns 5 \
--allowedTools Read,Write)
echo "$RESULT" | jq -r '.result'
echo "Cost: $(echo "$RESULT" | jq -r '.cost_usd') USD"
Stream-JSON Output
The stream-json format emits newline-delimited JSON events in real-time:
// Common event types in the stream
type StreamEvent =
| { type: "system"; message: string; session_id: string }
| { type: "assistant"; message: AssistantMessage }
| { type: "user"; message: UserMessage }
| { type: "result"; result: string; cost_usd: number; duration_ms: number; session_id: string };
interface AssistantMessage {
content: Array<
| { type: "text"; text: string }
| { type: "tool_use"; id: string; name: string; input: Record<string, unknown> }
>;
model: string;
stop_reason: string;
}
Building a Real-Time Activity Monitor
Using stream-json, you can build a dashboard that shows what Claude is doing in real time:
// monitor.ts β real-time Claude Code activity monitor
import { createInterface } from "readline";
import { spawn } from "child_process";
interface ToolCall {
name: string;
input: Record<string, unknown>;
timestamp: number;
}
interface SessionState {
sessionId: string;
startTime: number;
toolCalls: ToolCall[];
currentTool: string | null;
totalCost: number;
turns: number;
lastMessage: string;
}
function startMonitor(prompt: string, cwd: string): SessionState {
const state: SessionState = {
sessionId: "",
startTime: Date.now(),
toolCalls: [],
currentTool: null,
totalCost: 0,
turns: 0,
lastMessage: "",
};
const proc = spawn("claude", [
"-p", prompt,
"--output-format", "stream-json",
"--verbose",
"--max-turns", "20",
], {
cwd,
env: { ...process.env, CLAUDE_CODE_HEADLESS: "1" },
});
const rl = createInterface({ input: proc.stdout! });
rl.on("line", (line) => {
try {
const event = JSON.parse(line);
processEvent(event, state);
renderDashboard(state);
} catch {
// Skip non-JSON lines
}
});
proc.stderr?.on("data", (data) => {
// Log errors
console.error(`[stderr] ${data.toString().trim()}`);
});
proc.on("exit", (code) => {
console.log(`\nSession ended with code ${code}`);
console.log(`Total cost: $${state.totalCost.toFixed(4)}`);
console.log(`Total tool calls: ${state.toolCalls.length}`);
console.log(`Duration: ${((Date.now() - state.startTime) / 1000).toFixed(1)}s`);
});
return state;
}
function processEvent(event: Record<string, unknown>, state: SessionState) {
switch (event.type) {
case "system":
state.sessionId = (event as { session_id: string }).session_id;
break;
case "assistant": {
const msg = event as { message: { content: Array<Record<string, unknown>> } };
state.turns++;
for (const block of msg.message.content) {
if (block.type === "text") {
state.lastMessage = (block as { text: string }).text.slice(0, 200);
}
if (block.type === "tool_use") {
const tool = block as { name: string; input: Record<string, unknown> };
state.currentTool = tool.name;
state.toolCalls.push({
name: tool.name,
input: tool.input,
timestamp: Date.now(),
});
}
}
break;
}
case "result": {
const result = event as { cost_usd: number };
state.totalCost = result.cost_usd;
state.currentTool = null;
break;
}
}
}
function renderDashboard(state: SessionState) {
const elapsed = ((Date.now() - state.startTime) / 1000).toFixed(0);
const toolCounts = state.toolCalls.reduce((acc, t) => {
acc[t.name] = (acc[t.name] ?? 0) + 1;
return acc;
}, {} as Record<string, number>);
// Clear and redraw
process.stdout.write("\x1b[2J\x1b[H");
console.log("=== Claude Code Monitor ===");
console.log(`Session: ${state.sessionId}`);
console.log(`Elapsed: ${elapsed}s | Turns: ${state.turns} | Cost: $${state.totalCost.toFixed(4)}`);
console.log(`Current: ${state.currentTool ?? "thinking..."}`);
console.log("");
console.log("Tool Usage:");
for (const [tool, count] of Object.entries(toolCounts)) {
const bar = "\u2588".repeat(count);
console.log(` ${tool.padEnd(15)} ${bar} ${count}`);
}
console.log("");
console.log(`Last message: ${state.lastMessage}`);
}
// Usage
startMonitor("Refactor the auth module to use middleware pattern", "/path/to/project");
CI/CD Integration
Use headless mode in GitHub Actions:
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize]
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run Claude Code Review
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
npm install -g @anthropic-ai/claude-code
DIFF=$(git diff origin/main...HEAD)
RESULT=$(claude -p "Review this diff for bugs, security issues, and style:
$DIFF
Output a JSON object with: {issues: [{file, line, severity, message}], summary: string}" \
--output-format json \
--max-turns 3 \
--allowedTools Read,Grep,Glob)
echo "$RESULT" | jq -r '.result' > review.json
- name: Post Review Comment
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const review = JSON.parse(fs.readFileSync('review.json', 'utf8'));
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `## Claude Code Review\n\n${review.summary}\n\n${review.issues.map(i =>
`- **${i.severity}** ${i.file}:${i.line} - ${i.message}`
).join('\n')}`
});
Key insight: The headless API turns Claude Code from an interactive tool into a composable unix utility. Pipe data in, get structured data out. This is how you build Claude Code into your existing toolchain β not as a replacement for your workflow, but as a component within it.
Example 1: Auto-approve MCP Tool Calls
{
"hooks": {
"PreToolUse": [
{
"matcher": "mcp__sqlite-query__.*",
"hooks": [
{
"type": "command",
"command": "echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"permissionDecisionReason\":\"Read-only DB queries auto-approved\"}}'"
}
]
}
]
}
}
Example 2: Session Lock File
Prevent two Claude instances from working in the same directory simultaneously:
#!/usr/bin/env bash
LOCK_FILE="$PWD/.claude-session.lock"
if [[ -f "$LOCK_FILE" ]]; then
OTHER_PID=$(jq -r '.pid' "$LOCK_FILE" 2>/dev/null)
if kill -0 "$OTHER_PID" 2>/dev/null; then
echo "Another Claude session is active in this directory (PID: $OTHER_PID)" >&2
exit 0 # Warn but don't block
fi
fi
jq -n --arg pid "$$" --arg time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{ pid: $pid, started: $time }' > "$LOCK_FILE"
exit 0
Example 3: Token Budget Tracking
#!/usr/bin/env bash
BUDGET_FILE="$HOME/.claude/daily-budget.json"
MAX_DAILY_USD=50
TODAY=$(date +%Y-%m-%d)
if [[ -f "$BUDGET_FILE" ]]; then
BUDGET_DATE=$(jq -r '.date' "$BUDGET_FILE")
if [[ "$BUDGET_DATE" == "$TODAY" ]]; then
SPENT=$(jq -r '.spent' "$BUDGET_FILE")
else
SPENT=0
fi
else
SPENT=0
fi
INPUT=$(cat -)
SESSION_COST=$(echo "$INPUT" | jq -r '.session_cost // 0' 2>/dev/null || echo "0")
NEW_SPENT=$(python3 -c "print(round($SPENT + $SESSION_COST, 4))")
jq -n --arg date "$TODAY" --arg spent "$NEW_SPENT" \
'{ date: $date, spent: ($spent | tonumber) }' > "$BUDGET_FILE"
if (( $(python3 -c "print(1 if $NEW_SPENT > $MAX_DAILY_USD else 0)") )); then
echo "Daily budget exceeded: \$$NEW_SPENT / \$$MAX_DAILY_USD" >&2
fi
exit 0
Example 4: Git Branch Convention Enforcer
#!/usr/bin/env bash
INPUT=$(cat -)
PROMPT=$(echo "$INPUT" | jq -r '.prompt // ""')
if echo "$PROMPT" | grep -qiE 'create.*branch|new.*branch|checkout.*-b'; then
CURRENT_BRANCH=$(git branch --show-current 2>/dev/null)
# If on main, require branch name format
if [[ "$CURRENT_BRANCH" == "main" || "$CURRENT_BRANCH" == "master" ]]; then
if ! echo "$PROMPT" | grep -qE '(feat|fix|chore|docs|refactor)/[a-z0-9-]+'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext: "Branch names must follow the format: feat/description, fix/description, chore/description, etc."
}
}'
fi
fi
fi
exit 0
Example 5: Async Notification Daemon
#!/usr/bin/env bash
INPUT=$(cat -)
TYPE=$(echo "$INPUT" | jq -r '.notification_type // "unknown"')
MSG=$(echo "$INPUT" | jq -r '.message // "Claude Code notification"')
case "$TYPE" in
permission_prompt)
osascript -e "display notification \"$MSG\" with title \"Claude: Permission Needed\" sound name \"Ping\""
;;
idle_prompt)
osascript -e "display notification \"$MSG\" with title \"Claude: Waiting\" sound name \"Tink\""
;;
auth_success)
osascript -e "display notification \"Authentication successful\" with title \"Claude Code\""
;;
esac
exit 0
Example 6: Auto-Commit After Tests Pass
#!/usr/bin/env bash
INPUT=$(cat -)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
RESPONSE=$(echo "$INPUT" | jq -r '.tool_response // ""')
if echo "$COMMAND" | grep -qE '^(npm test|vitest|jest|pytest)' && \
echo "$RESPONSE" | grep -qiE 'pass|passed|ok'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PostToolUse",
additionalContext: "Tests passed. Consider committing these changes before moving on."
}
}'
fi
exit 0
Example 7: Pre-Write Input Modification
Ensure all TypeScript files include a file header:
#!/usr/bin/env bash
INPUT=$(cat -)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // ""')
if [[ "$TOOL_NAME" != "Write" ]]; then exit 0; fi
EXT="${FILE_PATH##*.}"
if [[ "$EXT" != "ts" && "$EXT" != "tsx" ]]; then exit 0; fi
if echo "$CONTENT" | head -1 | grep -q '^/\*\*'; then exit 0; fi
HEADER="/**
* $(basename "$FILE_PATH")
* Generated by Claude Code β $(date +%Y-%m-%d)
*/"
MODIFIED="${HEADER}
${CONTENT}"
jq -n --arg fp "$FILE_PATH" --arg content "$MODIFIED" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "allow",
permissionDecisionReason: "Added file header",
updatedInput: {
file_path: $fp,
content: $content
}
}
}'
Example 8: Conditional Tool Restriction by Directory
#!/usr/bin/env bash
INPUT=$(cat -)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
CWD=$(echo "$INPUT" | jq -r '.cwd // ""')
if echo "$CWD" | grep -qE '/production/|/prod/|/live/'; then
if [[ "$TOOL_NAME" == "Write" || "$TOOL_NAME" == "Edit" || "$TOOL_NAME" == "MultiEdit" ]]; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Write operations blocked in production directories"
}
}'
exit 0
fi
if [[ "$TOOL_NAME" == "Bash" ]]; then
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
if ! echo "$COMMAND" | grep -qE '^(cat|ls|head|tail|grep|git (log|status|diff|show))'; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Only read-only commands allowed in production directories"
}
}'
exit 0
fi
fi
fi
exit 0
Example 9: MCP Server for GitHub Issues
// github-issues-mcp.ts β MCP server for managing GitHub issues
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { execSync } from "child_process";
const server = new McpServer({
name: "github-issues",
version: "1.0.0",
});
server.tool(
"list-issues",
"List open GitHub issues for a repository",
{
repo: z.string().describe("Repository in owner/repo format"),
labels: z.string().optional().describe("Comma-separated labels to filter by"),
limit: z.number().optional().default(10),
},
async ({ repo, labels, limit }) => {
const labelFlag = labels ? `--label "${labels}"` : "";
const cmd = `gh issue list --repo ${repo} --state open ${labelFlag} --limit ${limit} --json number,title,labels,assignees,createdAt`;
try {
const output = execSync(cmd, { encoding: "utf-8" });
return {
content: [{ type: "text", text: output }],
};
} catch (error) {
return {
content: [{
type: "text",
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
}],
isError: true,
};
}
}
);
server.tool(
"create-issue",
"Create a new GitHub issue",
{
repo: z.string(),
title: z.string(),
body: z.string(),
labels: z.array(z.string()).optional(),
},
async ({ repo, title, body, labels }) => {
const labelFlags = labels?.map((l) => `--label "${l}"`).join(" ") ?? "";
const cmd = `gh issue create --repo ${repo} --title "${title}" --body "${body}" ${labelFlags}`;
try {
const output = execSync(cmd, { encoding: "utf-8" });
return {
content: [{ type: "text", text: `Issue created: ${output.trim()}` }],
};
} catch (error) {
return {
content: [{
type: "text",
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
}],
isError: true,
};
}
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
Example 10: Streaming Monitor with Cost Tracking
#!/usr/bin/env bash
claude -p "$1" \
--output-format stream-json \
--verbose \
--max-turns 15 \
2>/dev/null | while IFS= read -r line; do
TYPE=$(echo "$line" | jq -r '.type // "unknown"' 2>/dev/null)
case "$TYPE" in
system)
SESSION=$(echo "$line" | jq -r '.session_id')
echo "[SESSION] $SESSION"
;;
assistant)
# Extract tool calls
TOOLS=$(echo "$line" | jq -r '.message.content[]? | select(.type == "tool_use") | .name' 2>/dev/null)
TEXT=$(echo "$line" | jq -r '.message.content[]? | select(.type == "text") | .text' 2>/dev/null | head -1)
for tool in $TOOLS; do
echo "[TOOL] $tool"
done
if [[ -n "$TEXT" ]]; then
echo "[TEXT] ${TEXT:0:120}..."
fi
;;
result)
COST=$(echo "$line" | jq -r '.cost_usd')
DURATION=$(echo "$line" | jq -r '.duration_ms')
echo "[DONE] Cost: \$$COST | Duration: ${DURATION}ms"
;;
esac
done
Extension Mechanisms Compared
| Feature | Hooks | MCP Servers | Plugins | Channels | Headless API |
|---|---|---|---|---|---|
| Primary purpose | Intercept and control | Add tools and data | Package and distribute | Push events in | Programmatic output |
| Direction | Reactive (Claude triggers) | On-demand (Claude calls) | Bundling (all of the above) | Push (external triggers) | Output (consume results) |
| Can block actions | Yes (exit 2) | No | Yes (via hooks) | No | No |
| Can modify input | Yes (updatedInput) | No | Yes (via hooks) | No | No |
| Runs code | Yes (shell, HTTP, LLM) | Yes (server process) | Yes (all of the above) | Yes (server process) | Yes (Claude itself) |
| Shareable | Via settings.json | Via .mcp.json | Via marketplace | Via MCP server | Via CI/CD config |
| Real-time | Yes | Yes | Yes | Yes | Yes (stream-json) |
| Requires auth | No | Depends on server | No | Depends on channel | API key or OAuth |
Claude Code vs Other AI Coding Tools β Extensibility
| Feature | Claude Code | GitHub Copilot | Cursor | Cody (Sourcegraph) | Aider |
|---|---|---|---|---|---|
| Hook system | 24 lifecycle events, 4 handler types | None | None | None | None |
| Plugin system | Full plugin marketplace, skills, agents | VS Code extensions | Cursor rules | None | None |
| MCP support | Native, 3 transports | Limited (via VS Code) | Partial | None | None |
| Custom tools | MCP servers, unlimited | Extension API | @commands | Context filters | Shell commands |
| Headless/CI mode | --output-format json/stream-json | None | None | CLI mode | CLI native |
| Push events | Channels (Telegram, Discord, webhooks) | None | None | None | None |
| Custom agents | Subagents with own prompts/tools/models | None | None | None | None |
| Configuration | JSON settings cascade, 7 layers | VS Code settings | .cursorrules | cody.json | .aider.conf.yml |
| Multi-agent | Agent teams with worktrees | None | None | None | None |
| IDE integration | VS Code + JetBrains plugins | VS Code native | Cursor native | VS Code | Terminal only |
| Open source | Yes (CLI) | No | No | Partially | Yes |
Assessment: Claude Code has the most comprehensive extension system of any AI coding tool. The hook system alone β with 24 events and 4 handler types β is unmatched. The combination of hooks + plugins + MCP + channels creates a platform, not just a tool. The trade-off is complexity: thereβs a lot to learn, and the documentation is spread across multiple pages.
MCP Server Hosting Options
| Approach | Latency | Cost | Best For |
|---|---|---|---|
| stdio (local process) | <10ms | Free (your CPU) | Development, custom tools, file access |
| HTTP (remote) | 50-200ms | Server costs | Team-shared services, cloud APIs |
| SSE (remote, deprecated) | 50-200ms | Server costs | Legacy servers only |
| Plugin-bundled | <10ms | Free | Distributing tools with plugins |
| Cloudflare Worker | 20-50ms | Near-free | Always-on, globally distributed |
Hook Handler Type Comparison
| Handler Type | Speed | Complexity | Use Case |
|---|---|---|---|
| command | Fast (ms) | Shell scripting | Formatting, blocking, file ops |
| http | Medium (network) | Server required | Centralized policy, logging |
| prompt | Slow (2-15s) | Just a string | Complex policy decisions |
| agent | Slowest (10-60s) | Prompt + tools | Deep code analysis, verification |
Claude Codeβs extension points make it possible to build companion applications that run alongside it. Here are the key integration patterns.
Architecture of a Companion App
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Companion App β
β β
β ββββββββββββββ ββββββββββββββ ββββββββββββββββββββ β
β β Session β β Cost β β Activity Feed β β
β β Manager β β Dashboard β β (live updates) β β
β ββββββββ¬ββββββ ββββββββ¬ββββββ ββββββββββ¬ββββββββββ β
β β β β β
β βββββββββββββββββΌβββββββββββββββββββ β
β β β
β ββββββββββββ΄βββββββββββ β
β β Data Collection β β
β ββββββββββββ¬βββββββββββ β
β β β
βββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββΌββββββββββββββββ
β β β
βββββββ΄ββββββ ββββββ΄βββββ βββββββ΄βββββββ
β Hooks β β Stream β β Transcript β
β (events) β β JSON β β Files β
βββββββββββββ βββββββββββ ββββββββββββββ
Data Sources for Companion Apps
1. Hook Events (real-time, event-driven)
Use async hooks to report events without blocking Claude:
{
"hooks": {
"PreToolUse": [
{
"hooks": [
{
"type": "http",
"url": "http://localhost:3000/events/tool-use",
"async": true,
"timeout": 2
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "http",
"url": "http://localhost:3000/events/session-stop",
"async": true,
"timeout": 2
}
]
}
]
}
}
2. Stream-JSON (real-time, output-driven)
For headless sessions, parse the stream directly:
import { spawn } from "child_process";
import { createInterface } from "readline";
import { WebSocketServer } from "ws";
// WebSocket server for the companion UI
const wss = new WebSocketServer({ port: 8080 });
const clients = new Set<WebSocket>();
wss.on("connection", (ws) => {
clients.add(ws);
ws.on("close", () => clients.delete(ws));
});
function broadcast(event: Record<string, unknown>) {
const msg = JSON.stringify(event);
for (const client of clients) {
client.send(msg);
}
}
// Start Claude and stream events to WebSocket clients
function monitorSession(prompt: string, cwd: string) {
const proc = spawn("claude", [
"-p", prompt,
"--output-format", "stream-json",
"--verbose",
], { cwd });
const rl = createInterface({ input: proc.stdout! });
rl.on("line", (line) => {
try {
const event = JSON.parse(line);
broadcast({
type: "claude-event",
event,
timestamp: Date.now(),
});
} catch {
// Non-JSON line
}
});
proc.on("exit", (code) => {
broadcast({
type: "session-end",
exitCode: code,
timestamp: Date.now(),
});
});
}
3. Transcript Files (offline, historical)
Claude Code stores transcripts in ~/.claude/.sessions/. Each session has a .jsonl file:
import { readFileSync, readdirSync } from "fs";
import { join } from "path";
interface TranscriptEntry {
type: string;
timestamp: string;
message?: {
role: string;
content: Array<{
type: string;
text?: string;
name?: string;
}>;
};
}
function readTranscript(sessionDir: string): TranscriptEntry[] {
const files = readdirSync(sessionDir).filter((f) => f.endsWith(".jsonl"));
const entries: TranscriptEntry[] = [];
for (const file of files) {
const content = readFileSync(join(sessionDir, file), "utf-8");
for (const line of content.split("\n")) {
if (line.trim()) {
try {
entries.push(JSON.parse(line));
} catch {
// Skip malformed lines
}
}
}
}
return entries;
}
function analyzeSession(entries: TranscriptEntry[]) {
const toolCalls = entries.filter((e) =>
e.message?.content?.some((c) => c.type === "tool_use")
);
const toolUsage = toolCalls.reduce((acc, entry) => {
for (const content of entry.message?.content ?? []) {
if (content.type === "tool_use" && content.name) {
acc[content.name] = (acc[content.name] ?? 0) + 1;
}
}
return acc;
}, {} as Record<string, number>);
return {
totalEntries: entries.length,
toolCalls: toolCalls.length,
toolUsage,
};
}
Notch HUD Integration Concept
A notch HUD (heads-up display) sits in the macOS menu bar or as an overlay, showing Claude Codeβs current state:
// notch-hud-server.ts β local HTTP server that powers a HUD overlay
import { Hono } from "hono";
import { serve } from "@hono/node-server";
interface SessionState {
active: boolean;
project: string;
currentTool: string | null;
toolCount: number;
elapsed: number;
cost: number;
lastActivity: number;
status: "working" | "waiting" | "idle" | "error";
}
const state: SessionState = {
active: false,
project: "",
currentTool: null,
toolCount: 0,
elapsed: 0,
cost: 0,
lastActivity: Date.now(),
status: "idle",
};
const app = new Hono();
// Hook endpoints β Claude Code sends events here
app.post("/events/session-start", async (c) => {
const body = await c.req.json();
state.active = true;
state.project = body.cwd?.split("/").pop() ?? "unknown";
state.elapsed = 0;
state.toolCount = 0;
state.cost = 0;
state.status = "working";
state.lastActivity = Date.now();
return c.json({ ok: true });
});
app.post("/events/tool-use", async (c) => {
const body = await c.req.json();
state.currentTool = body.tool_name;
state.toolCount++;
state.status = "working";
state.lastActivity = Date.now();
return c.json({ ok: true });
});
app.post("/events/session-stop", async (c) => {
state.active = false;
state.currentTool = null;
state.status = "idle";
return c.json({ ok: true });
});
app.post("/events/notification", async (c) => {
const body = await c.req.json();
if (body.notification_type === "permission_prompt") {
state.status = "waiting";
}
return c.json({ ok: true });
});
// HUD reads state from this endpoint
app.get("/state", (c) => {
return c.json({
...state,
elapsed: state.active ? Math.floor((Date.now() - state.lastActivity) / 1000) : 0,
});
});
// Simple HTML HUD page
app.get("/hud", (c) => {
return c.html(`
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: -apple-system, system-ui, sans-serif;
background: rgba(0, 0, 0, 0.85);
color: white;
margin: 0;
padding: 8px 16px;
font-size: 12px;
-webkit-app-region: drag;
}
.status { display: flex; align-items: center; gap: 8px; }
.dot { width: 8px; height: 8px; border-radius: 50%; }
.dot.working { background: #22c55e; animation: pulse 1s infinite; }
.dot.waiting { background: #eab308; animation: pulse 0.5s infinite; }
.dot.idle { background: #6b7280; }
.dot.error { background: #ef4444; }
@keyframes pulse { 50% { opacity: 0.5; } }
.stats { display: flex; gap: 16px; margin-top: 4px; color: #9ca3af; }
</style>
</head>
<body>
<div class="status">
<div class="dot" id="dot"></div>
<span id="project">-</span>
<span id="tool" style="color: #60a5fa">-</span>
</div>
<div class="stats">
<span id="tools">0 tools</span>
<span id="cost">$0.00</span>
</div>
<script>
async function update() {
const res = await fetch('/state');
const s = await res.json();
document.getElementById('dot').className = 'dot ' + s.status;
document.getElementById('project').textContent = s.project || '-';
document.getElementById('tool').textContent = s.currentTool || '';
document.getElementById('tools').textContent = s.toolCount + ' tools';
document.getElementById('cost').textContent = '$' + s.cost.toFixed(2);
}
setInterval(update, 1000);
update();
</script>
</body>
</html>
`);
});
serve({ fetch: app.fetch, port: 3456 });
console.log("HUD server running on http://localhost:3456/hud");
Hook configuration to feed the HUD:
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "http",
"url": "http://localhost:3456/events/session-start",
"timeout": 2
}
]
}
],
"PreToolUse": [
{
"hooks": [
{
"type": "http",
"url": "http://localhost:3456/events/tool-use",
"async": true,
"timeout": 2
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "http",
"url": "http://localhost:3456/events/session-stop",
"async": true,
"timeout": 2
}
]
}
],
"Notification": [
{
"hooks": [
{
"type": "http",
"url": "http://localhost:3456/events/notification",
"async": true,
"timeout": 2
}
]
}
]
}
}
Official Plugins
The Anthropic plugin marketplace ships curated, high-quality plugins:
| Plugin | Category | What It Does |
|---|---|---|
typescript-lsp | Code Intelligence | TypeScript language server for jump-to-definition, diagnostics |
swift-lsp | Code Intelligence | Swift language server integration |
frontend-design | Development | Frontend design patterns and component generation |
superpowers | Utilities | Extended capabilities (details vary by version) |
Community Highlights
| Project | What It Does |
|---|---|
| awesome-claude-code | Curated skills, hooks, slash-commands, orchestrators |
| claude-code-hooks-mastery | Step-by-step hook examples with explanations |
| claude-hooks | Reusable hook scripts for common patterns |
| claude-code-monitor | Multi-session monitoring with mobile web UI |
| Claude-Code-Agent-Monitor | React + WebSocket real-time monitoring |
| Claude-Code-Usage-Monitor | Token burn rate tracking with predictions |
| awesome-claude-plugins | Curated plugin directory with categories |
| claude-code-plugins-plus-skills | 340+ plugins with CCPI package manager |
| awesome-claude-code-subagents | 100+ specialized subagent definitions |
| claude-code-config | Opinionated security-focused defaults by Trail of Bits |
IDE Extensions
| IDE | Extension | Features |
|---|---|---|
| VS Code | Claude Code Extension | Inline diff view, file selection, multi-tab conversations |
| JetBrains | Claude Code Plugin | Terminal integration, diff viewer, problems panel |
| VS Code | Claude Token Monitor | Token usage tracking in status bar |
Monitoring and Observability
| Tool | Type | What It Shows |
|---|---|---|
| Datadog AI Agents Console | Enterprise | Organization-wide Claude Code telemetry |
| Claude HUD | Plugin | Context window usage, active tools, agent status |
| Claude Code Usage Monitor | Terminal | Token burn rate, cost predictions, session limits |
| claude-code-monitor | Web + CLI | Multi-session dashboard with mobile QR access |
Agent teams let you coordinate multiple Claude Code instances working in parallel, each with their own git worktree and context window.
Enabling Agent Teams
{
"experimentalFeatures": {
"agentTeams": true
}
}
Or set the environment variable:
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 claude
How Teams Work
You: "Implement the auth module with frontend, backend, and tests"
Claude (Team Lead):
βββ Teammate 1: "Implement backend auth middleware" β worktree-1/
βββ Teammate 2: "Build login/signup React components" β worktree-2/
βββ Teammate 3: "Write integration tests for auth flow" β worktree-3/
Each teammate:
- Has its own context window
- Works in its own git worktree (no merge conflicts)
- Can communicate with other teammates via tasks
- Reports back to the team lead
Hooks for Agent Teams
Several hooks are specifically designed for team orchestration:
{
"hooks": {
"TaskCreated": [
{
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/validate-task.sh"
}
]
}
],
"TaskCompleted": [
{
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/verify-task-completion.sh"
}
]
}
],
"TeammateIdle": [
{
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/check-teammate-work.sh"
}
]
}
],
"WorktreeCreate": [
{
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/setup-worktree.sh"
}
]
}
]
}
}
Worktree Setup Hook
#!/usr/bin/env bash
INPUT=$(cat -)
WORKTREE_PATH=$(echo "$INPUT" | jq -r '.worktree_path // ""')
if [[ -n "$WORKTREE_PATH" && -d "$WORKTREE_PATH" ]]; then
cd "$WORKTREE_PATH"
# Install dependencies in the worktree
if [[ -f "package.json" ]]; then
npm install --silent 2>/dev/null &
fi
# Copy local env if it exists
if [[ -f "../.env" ]]; then
cp ../.env .env
fi
fi
exit 0
| Donβt | Do Instead | Why |
|---|---|---|
| Put secrets in hook scripts | Use $CLAUDE_ENV_FILE or --env flags | Secrets in scripts end up in git history |
| Use synchronous hooks for logging | Use "async": true for non-blocking hooks | Synchronous hooks block Claudeβs execution |
| Write hooks that exit 1 on expected conditions | Use exit 0 with JSON output for expected states | Exit 1 is βnon-blocking errorβ β it logs warnings |
| Block everything with PreToolUse | Use allowlists with permissionDecision: "allow" | Over-blocking makes Claude unusable |
| Install every MCP server you find | Add only servers you actually need | Each server consumes context tokens on every turn |
Put all hooks in ~/.claude/settings.json | Use project-level .claude/settings.json for project-specific hooks | Global hooks affect every project |
Skip the if field on hooks | Use if: "Bash(git *)" for coarse filtering | Without if, your script runs on every Bash call |
| Write hooks that modify files without backing up | Use PostToolUse (file already written) or updatedInput (modify before write) | PreToolUse scripts that write files can corrupt state |
Create plugins without --plugin-dir testing | Always test locally before publishing | Broken plugins affect every session |
| Ignore hook timeouts | Set explicit timeouts on all HTTP and prompt hooks | Default timeouts may be too long for your workflow |
| Use prompt hooks for simple checks | Use command hooks with grep/jq | Prompt hooks cost money and add 2-15s latency |
Run claude -p without --max-turns | Always set --max-turns in CI/CD | Unbounded turns can run forever and cost a fortune |
| Hardcode tool names in matchers | Use regex patterns like mcp__.* for groups | New tools from MCP servers wonβt be caught |
Skip stop_hook_active check in Stop hooks | Always check and exit 0 if true | Without it, the Stop hook loops forever |
For Individual Developers
- Start with the guardrail stack (Pattern 1). Block destructive commands, protect secrets files, auto-approve safe read-only operations.
- Add auto-formatting (Pattern 2). Let Claude write messy code β your PostToolUse hook will clean it up.
- Install the TypeScript LSP plugin. The code intelligence it provides (jump-to-definition, diagnostics) dramatically improves Claudeβs code quality.
- Set up notifications. The Notification hook with macOS
osascriptmeans you never miss when Claude needs input. - Use tmux window naming. If you run multiple sessions, the tmux integration (Pattern 6) is essential for sanity.
For Teams
- Create a team plugin that bundles your coding standards, hooks, and MCP servers. Distribute via a private marketplace.
- Use project-level
.claude/settings.jsonfor repo-specific hooks and check it into git. Every developer gets the same guardrails. - Share MCP servers via
.mcp.jsonat the repo root. Database access, internal APIs, and design systems should be configured once. - Set up centralized logging with HTTP hooks (Pattern 4). You need visibility into what Claude is doing across the team.
- Enforce branch naming and commit conventions with UserPromptSubmit and PreToolUse hooks.
For Building Companion Apps
- Use async HTTP hooks for real-time event data. They donβt block Claude and give you structured events.
- Use stream-json for headless sessions in CI/CD. Itβs the richest data source.
- Read transcript files for historical analysis. Theyβre JSONL and easy to parse.
- Build your HUD as a local web server that Claudeβs hooks POST to. The HUD page polls the server for state. Keep the architecture simple.
- Donβt try to modify Claudeβs behavior from the companion app. Use hooks for control, the companion for visibility.
Official Documentation
- Hooks Reference β Claude Code Docs β Complete lifecycle event reference with input/output schemas
- Connect Claude Code to tools via MCP β MCP server installation, transports, and configuration
- Create Plugins β Claude Code Docs β Plugin quickstart, structure, and distribution
- Plugins Reference β Claude Code Docs β Full plugin manifest schema and technical specs
- Extend Claude with Skills β Skill frontmatter, invocation, and best practices
- Create Custom Subagents β Agent definition, tool restrictions, and models
- Slash Commands β Claude Code Docs β Built-in and custom slash commands
- Run Claude Code Programmatically β Headless mode, output formats, CI/CD integration
- Claude Code Settings β Settings hierarchy, permissions, and configuration
- Use Claude Code in VS Code β VS Code extension features and setup
- Channels Reference β Channel contract, capability declaration, push events
- Monitoring Usage β Usage tracking and telemetry
- Orchestrate Teams of Claude Code Sessions β Agent teams, worktrees, and task coordination
- Discover and Install Plugins β Plugin marketplace and installation
- Claude Plugins Official Directory β Anthropic-managed plugin directory
MCP Protocol
- Model Context Protocol β Introduction β MCP specification and concepts
- MCP SDK β Build Servers β Quickstart for building MCP servers
- MCP Servers on GitHub β Community MCP server implementations
- Connect to Local MCP Servers β Local server setup guide
Tutorials and Guides
- Claude Code Hooks Tutorial: 5 Production Hooks From Scratch β Step-by-step hook development
- How I Automated My Entire Claude Code Workflow with Hooks β Real-world automation patterns
- How to Build Claude Code Plugins: A Step-by-Step Guide (DataCamp) β Comprehensive plugin building tutorial
- Claude Code Hooks (DataCamp) β Practical hook guide with examples
- Essential Claude Code Skills and Commands β Skills overview and best practices
- Inside Claude Code Skills: Structure, Prompts, Invocation β Deep dive into skill internals
- Building My First Claude Code Plugin β First-person plugin development walkthrough
- Claude Code Customization Guide β CLAUDE.md, skills, and subagents
- Configuring MCP Tools in Claude Code β Practical MCP setup guide
- Claude Code MCP Servers: How to Connect, Configure, and Use Them β Builder.io MCP guide
Community Projects
- awesome-claude-code β Curated list of skills, hooks, commands, and plugins
- awesome-claude-plugins (Composio) β Plugin directory with categories
- claude-code-hooks-mastery β Hook mastery tutorial repository
- claude-hooks β Reusable hook script library
- claude-code-monitor β Multi-session monitoring dashboard
- Claude-Code-Agent-Monitor β Real-time agent monitoring with React + WebSockets
- Claude-Code-Usage-Monitor β Terminal-based usage tracking
- claude-code-plugins-plus-skills β 340+ plugins marketplace
- awesome-claude-code-subagents β 100+ specialized subagents
- claude-code-config (Trail of Bits) β Security-focused configuration defaults
- claude-skills-starter β Starter template for custom skills
IDE Integrations
- Claude Code Plugin for JetBrains IDEs β Official JetBrains plugin
- Claude Token Monitor (VS Code) β Token usage tracking extension
Cheat Sheets and References
- Claude Code CLI Cheatsheet (Shipyard) β Config, commands, prompts, and best practices
- Claude Code Complete Command Reference (SmartScope) β CLI, slash commands, and shortcuts
- Claude Code Hooks Complete Guide (SmartScope) β All hook events with examples
- Claude Code Commands Cheat Sheet (ScriptByAI) β Quick reference for all commands
- Every Claude Code Command You Need to Know β Commands with examples
Enterprise and Monitoring
- Monitor Claude Code with Datadog β Enterprise telemetry and AI Agents Console
- Claude Code settings.json Complete Guide (eesel AI) β Configuration guide for teams
- 10 Top Claude Code Plugins (Composio) β Plugin recommendations
Channels and Event-Driven Patterns
- Claude Code Channels (Low Code Agency) β What channels are and how they work
- Claude Code Channels: Telegram and Discord Guide β Setting up chat bridges
- Claude Code Channels β How Anthropic Built a Two-Way Bridge β Architecture deep dive