Skip to content
Gary Wu
Go back

Claude Code Context Monitoring

Edit page

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:


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 PointWhat It DoesWhen To Use It
HooksIntercept lifecycle events (tool calls, session start/stop, file changes)Guardrails, formatting, logging, notifications
PluginsPackage skills + agents + hooks + MCP servers into a distributable unitSharing workflows across repos and teams
MCP ServersAdd custom tools and data sources via the Model Context ProtocolConnecting to databases, APIs, external services
ChannelsPush external events into a running sessionChat bridges, webhooks, CI notifications
Headless APIRun Claude Code programmatically with structured outputCI/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.allow list uses a glob syntax: "Bash(git:*)" allows any bash command starting with git. 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 CodeMeaningEffect
0SuccessProceed normally; parse stdout as JSON for output fields
2BlockBlock the action; stderr is fed to Claude as feedback
Any otherNon-blocking errorContinue; 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 EventBlockable?Effect of Block (exit 2)
PreToolUseYesBlocks tool call
PermissionRequestYesDenies permission
UserPromptSubmitYesBlocks prompt, erases it
StopYesPrevents stopping, Claude continues
SubagentStopYesPrevents subagent from stopping
TeammateIdleYesPrevents idle, gives feedback
TaskCreatedYesPrevents task creation
TaskCompletedYesPrevents task completion
ConfigChangeYesBlocks config change (except policy)
ElicitationYesDenies elicitation
ElicitationResultYesBlocks response (becomes decline)
WorktreeCreateYesFails worktree creation
PostToolUseSoftTool already ran; stderr fed to Claude
PostToolUseFailureNoShows stderr to Claude
NotificationNoShows stderr to user only
SubagentStartNoShows stderr to user only
SessionStartNoShows stderr to user only
SessionEndNoShows stderr to user only
InstructionsLoadedNoInformational only
CwdChangedNoInformational only
FileChangedNoInformational only
StopFailureNoOutput and exit code ignored
PreCompactNoInformational only
PostCompactNoInformational 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 if field 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 --force buried in a longer command), you must read stdin in the script. The if field 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 timeout explicitly 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.json file 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:

FieldRequiredDescription
nameYesSkill identifier
descriptionYesWhen Claude should use this skill (model-facing)
allowed-toolsNoRestrict which tools this skill can use
disabled-toolsNoTools this skill cannot use
disable-model-invocationNoIf true, only user can invoke (not Claude autonomously)
user-invocableNoIf false, only Claude can invoke (background knowledge)
modelNoOverride the model for this skill
contextNoAdditional files/directories to load
agentNoRun 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:

  1. Push notifications into the session
  2. Optionally expose a reply tool so Claude can respond

Built-in Channels

As of March 2026, three official channels ship with Claude Code:

ChannelDirectionUse Case
TelegramTwo-wayControl Claude from your phone
DiscordTwo-wayTeam-based Claude interaction
iMessageTwo-wayPersonal 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

FeatureHooksMCP ServersPluginsChannelsHeadless API
Primary purposeIntercept and controlAdd tools and dataPackage and distributePush events inProgrammatic output
DirectionReactive (Claude triggers)On-demand (Claude calls)Bundling (all of the above)Push (external triggers)Output (consume results)
Can block actionsYes (exit 2)NoYes (via hooks)NoNo
Can modify inputYes (updatedInput)NoYes (via hooks)NoNo
Runs codeYes (shell, HTTP, LLM)Yes (server process)Yes (all of the above)Yes (server process)Yes (Claude itself)
ShareableVia settings.jsonVia .mcp.jsonVia marketplaceVia MCP serverVia CI/CD config
Real-timeYesYesYesYesYes (stream-json)
Requires authNoDepends on serverNoDepends on channelAPI key or OAuth

Claude Code vs Other AI Coding Tools β€” Extensibility

FeatureClaude CodeGitHub CopilotCursorCody (Sourcegraph)Aider
Hook system24 lifecycle events, 4 handler typesNoneNoneNoneNone
Plugin systemFull plugin marketplace, skills, agentsVS Code extensionsCursor rulesNoneNone
MCP supportNative, 3 transportsLimited (via VS Code)PartialNoneNone
Custom toolsMCP servers, unlimitedExtension API@commandsContext filtersShell commands
Headless/CI mode--output-format json/stream-jsonNoneNoneCLI modeCLI native
Push eventsChannels (Telegram, Discord, webhooks)NoneNoneNoneNone
Custom agentsSubagents with own prompts/tools/modelsNoneNoneNoneNone
ConfigurationJSON settings cascade, 7 layersVS Code settings.cursorrulescody.json.aider.conf.yml
Multi-agentAgent teams with worktreesNoneNoneNoneNone
IDE integrationVS Code + JetBrains pluginsVS Code nativeCursor nativeVS CodeTerminal only
Open sourceYes (CLI)NoNoPartiallyYes

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

ApproachLatencyCostBest For
stdio (local process)<10msFree (your CPU)Development, custom tools, file access
HTTP (remote)50-200msServer costsTeam-shared services, cloud APIs
SSE (remote, deprecated)50-200msServer costsLegacy servers only
Plugin-bundled<10msFreeDistributing tools with plugins
Cloudflare Worker20-50msNear-freeAlways-on, globally distributed

Hook Handler Type Comparison

Handler TypeSpeedComplexityUse Case
commandFast (ms)Shell scriptingFormatting, blocking, file ops
httpMedium (network)Server requiredCentralized policy, logging
promptSlow (2-15s)Just a stringComplex policy decisions
agentSlowest (10-60s)Prompt + toolsDeep 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:

PluginCategoryWhat It Does
typescript-lspCode IntelligenceTypeScript language server for jump-to-definition, diagnostics
swift-lspCode IntelligenceSwift language server integration
frontend-designDevelopmentFrontend design patterns and component generation
superpowersUtilitiesExtended capabilities (details vary by version)

Community Highlights

ProjectWhat It Does
awesome-claude-codeCurated skills, hooks, slash-commands, orchestrators
claude-code-hooks-masteryStep-by-step hook examples with explanations
claude-hooksReusable hook scripts for common patterns
claude-code-monitorMulti-session monitoring with mobile web UI
Claude-Code-Agent-MonitorReact + WebSocket real-time monitoring
Claude-Code-Usage-MonitorToken burn rate tracking with predictions
awesome-claude-pluginsCurated plugin directory with categories
claude-code-plugins-plus-skills340+ plugins with CCPI package manager
awesome-claude-code-subagents100+ specialized subagent definitions
claude-code-configOpinionated security-focused defaults by Trail of Bits

IDE Extensions

IDEExtensionFeatures
VS CodeClaude Code ExtensionInline diff view, file selection, multi-tab conversations
JetBrainsClaude Code PluginTerminal integration, diff viewer, problems panel
VS CodeClaude Token MonitorToken usage tracking in status bar

Monitoring and Observability

ToolTypeWhat It Shows
Datadog AI Agents ConsoleEnterpriseOrganization-wide Claude Code telemetry
Claude HUDPluginContext window usage, active tools, agent status
Claude Code Usage MonitorTerminalToken burn rate, cost predictions, session limits
claude-code-monitorWeb + CLIMulti-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’tDo InsteadWhy
Put secrets in hook scriptsUse $CLAUDE_ENV_FILE or --env flagsSecrets in scripts end up in git history
Use synchronous hooks for loggingUse "async": true for non-blocking hooksSynchronous hooks block Claude’s execution
Write hooks that exit 1 on expected conditionsUse exit 0 with JSON output for expected statesExit 1 is β€œnon-blocking error” β€” it logs warnings
Block everything with PreToolUseUse allowlists with permissionDecision: "allow"Over-blocking makes Claude unusable
Install every MCP server you findAdd only servers you actually needEach server consumes context tokens on every turn
Put all hooks in ~/.claude/settings.jsonUse project-level .claude/settings.json for project-specific hooksGlobal hooks affect every project
Skip the if field on hooksUse if: "Bash(git *)" for coarse filteringWithout if, your script runs on every Bash call
Write hooks that modify files without backing upUse PostToolUse (file already written) or updatedInput (modify before write)PreToolUse scripts that write files can corrupt state
Create plugins without --plugin-dir testingAlways test locally before publishingBroken plugins affect every session
Ignore hook timeoutsSet explicit timeouts on all HTTP and prompt hooksDefault timeouts may be too long for your workflow
Use prompt hooks for simple checksUse command hooks with grep/jqPrompt hooks cost money and add 2-15s latency
Run claude -p without --max-turnsAlways set --max-turns in CI/CDUnbounded turns can run forever and cost a fortune
Hardcode tool names in matchersUse regex patterns like mcp__.* for groupsNew tools from MCP servers won’t be caught
Skip stop_hook_active check in Stop hooksAlways check and exit 0 if trueWithout it, the Stop hook loops forever

For Individual Developers

  1. Start with the guardrail stack (Pattern 1). Block destructive commands, protect secrets files, auto-approve safe read-only operations.
  2. Add auto-formatting (Pattern 2). Let Claude write messy code β€” your PostToolUse hook will clean it up.
  3. Install the TypeScript LSP plugin. The code intelligence it provides (jump-to-definition, diagnostics) dramatically improves Claude’s code quality.
  4. Set up notifications. The Notification hook with macOS osascript means you never miss when Claude needs input.
  5. Use tmux window naming. If you run multiple sessions, the tmux integration (Pattern 6) is essential for sanity.

For Teams

  1. Create a team plugin that bundles your coding standards, hooks, and MCP servers. Distribute via a private marketplace.
  2. Use project-level .claude/settings.json for repo-specific hooks and check it into git. Every developer gets the same guardrails.
  3. Share MCP servers via .mcp.json at the repo root. Database access, internal APIs, and design systems should be configured once.
  4. Set up centralized logging with HTTP hooks (Pattern 4). You need visibility into what Claude is doing across the team.
  5. Enforce branch naming and commit conventions with UserPromptSubmit and PreToolUse hooks.

For Building Companion Apps

  1. Use async HTTP hooks for real-time event data. They don’t block Claude and give you structured events.
  2. Use stream-json for headless sessions in CI/CD. It’s the richest data source.
  3. Read transcript files for historical analysis. They’re JSONL and easy to parse.
  4. 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.
  5. Don’t try to modify Claude’s behavior from the companion app. Use hooks for control, the companion for visibility.

Official Documentation

MCP Protocol

Tutorials and Guides

Community Projects

IDE Integrations

Cheat Sheets and References

Enterprise and Monitoring

Channels and Event-Driven Patterns


Edit page
Share this post on:

Previous Post
AI Usage Postmortem
Next Post
Autonomous Agent Frameworks