Intercepting Agent Execution with Hooks

Introduction: Why Hooks Matter

Welcome to this course on exploring advanced features of the Claude Agent SDK in TypeScript! In this first lesson, we're diving into one of the most powerful capabilities the SDK offers: hooks. When you build AI agents that can execute commands, read files, or interact with systems, safety becomes critical. Hooks are callback functions that let you intercept and control your agent's behavior at key moments during execution, acting as checkpoints where you can inspect what is about to happen and decide whether to allow it.

The TypeScript SDK provides a HookCallback type for defining these functions, and you register them through the Options object when calling query(). The query() function returns an AsyncIterable<SDKMessage> that yields messages as the agent processes your request — and your hooks execute at specific points in this flow, giving you fine-grained control.

In this lesson, you will learn how to attach hooks to your agent's lifecycle events, inspect incoming user prompts, and enforce safety rules before tools execute. By the end, you will have working code that blocks dangerous requests and prevents risky commands from running.

Available Hook Types

The Claude Agent SDK provides multiple hook types that trigger at various points in your agent's execution lifecycle. In this lesson, we'll focus on implementing the two most commonly used hooks that form the foundation of a robust safety system:

  • UserPromptSubmit: Fires when a user submits a prompt, before the agent processes it, giving you a first line of defense for analyzing user intent.
  • PreToolUse: Triggers right before the agent executes any tool, allowing you to validate whether a specific action should be allowed.

These two hooks work together to create a defense-in-depth approach: the UserPromptSubmit hook catches problems at the prompt level (blocking malicious intent before it reaches the agent), while the PreToolUse hook provides a second layer of protection at the execution level (preventing dangerous commands from running even if they slip through intent validation).

Understanding the Hook Callback Structure

Every hook function follows the same signature pattern defined by the HookCallback type. It is an asynchronous function that receives three parameters and must return an object that controls what happens next.

TypeScript
import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";

const myHook: HookCallback = async (input, toolUseID, { signal }) => {
  // Your logic here
  return {}; // Empty object means "proceed normally"
};

The hook function signature has three key parameters:

  • input: An object containing event-specific information that varies by hook type. You will need to cast this to the appropriate type to access its properties.
  • toolUseID: An optional identifier for the tool invocation, useful for tracking specific executions.
  • { signal }: A destructured object containing execution metadata (primarily for cancellation support and future extensibility).

Returning an empty object signals that everything is correct and that the agent should proceed normally. To block or modify behavior, you must return a hook-specific response.

Working with Hook Inputs and Decisions

While the generic signature is always the same, the shape of the input and the required return object depend on which hook you are using. You must cast the input to access properties and follow the specific return structure for that event.

Important: The SDK uses strict literal types for decisions. Using as const type assertions ensures TypeScript treats strings as specific literal types (like "block" or "deny") rather than general strings.

For UserPromptSubmit, you cast input to UserPromptSubmitHookInput and return a prompt-level decision:

TypeScript
import type { UserPromptSubmitHookInput } from "@anthropic-ai/claude-agent-sdk";

const hookInput = input as UserPromptSubmitHookInput;
// To block:
return {
  decision: "block" as const,
  systemMessage: "Your request was blocked..."
};

For PreToolUse, you cast input to PreToolUseHookInput and return a tool-permission decision under hookSpecificOutput:

TypeScript
import type { PreToolUseHookInput } from "@anthropic-ai/claude-agent-sdk";

const hookInput = input as PreToolUseHookInput;
// To deny:
return {
  hookSpecificOutput: {
    hookEventName: "PreToolUse" as const,
    permissionDecision: "deny" as const,
    permissionDecisionReason: "Command contains forbidden pattern..."
  }
};

Understanding this pattern of casting and structured returns is crucial because only the casting of input and the expected return keys change depending on which hook type is triggered. Now, let's use this pattern to build our two safety hooks.

Building an Intent Guardrail: The Basic Structure

Our first hook will inspect user prompts before the agent processes them. Let's start with the basic structure that extracts and logs the user's prompt.

TypeScript
import type {
  HookCallback,
  UserPromptSubmitHookInput,
} from "@anthropic-ai/claude-agent-sdk";

const intentGuardrail: HookCallback = async (input, toolUseID, { signal }) => {
  // Runs before the agent sees the user prompt
  // Cast input to the correct type to access prompt-specific properties
  const hookInput = input as UserPromptSubmitHookInput;
  
  // Extract the user's prompt and convert to lowercase for case-insensitive matching
  const userPrompt = hookInput.prompt.toLowerCase();
  
  // Print the prompt for debugging and transparency
  console.log(`\n[HOOK] Checking user intent: ${userPrompt}`);
  
  // Keyword checking logic will be added here...
  
  // If no issues found, allow the prompt through
  return {};
};

We cast input to UserPromptSubmitHookInput to access the prompt property safely, then convert it to lowercase for case-insensitive matching. The console.log helps us see what the hook is inspecting during execution. This basic structure follows the "cast → normalize → return" pattern that all hooks use.

Building an Intent Guardrail: Adding Keyword Blocking

Now, we will add the keyword-checking logic between the console.log and the return {} statement:

TypeScript
// Define keywords that indicate potentially malicious or unsafe requests
// This list includes common attack vectors and prompt injection attempts
const blockedKeywords = [
  "hack",                              // Indicates hacking attempts
  "exploit",                           // Indicates exploitation attempts
  "steal",                             // Indicates intent to steal data/credentials
  "credential",                        // Indicates requests for sensitive data
  "password",                          // Indicates requests for authentication data
  "ignore all previous instructions",  // Indicates prompt injection attack
];

// Iterate through each blocked keyword to see if any appear in the user prompt
for (const word of blockedKeywords) {
  if (userPrompt.includes(word)) {
    // Log which keyword triggered the block (helpful for debugging)
    console.log(`[HOOK] Blocking prompt due to keyword: ${word}`);
    
    // Return a block decision with a user-facing explanation
    // The 'systemMessage' will be shown to the user explaining why their request was denied
    // Use 'as const' to ensure TypeScript treats "block" as a literal type
    return {
      decision: "block" as const,
      systemMessage:
        "Your request was blocked because it appears to ask for " +
        "disallowed or unsafe behavior.",
    };
  }
}

This completes our intent guardrail by adding the "scan keywords" step to our pattern. We define an array of blockedKeywords representing dangerous or malicious intent. When a match is found, we return an object with decision set to "block" as const and a systemMessage providing a user-facing explanation. The as const assertion ensures TypeScript treats "block" as the specific literal type required by the SDK. If no blocked keywords are found, execution continues to the final return {} that allows the prompt through.

Building a Bash Safety Guardrail: The Basic Structure

Our second hook operates at a different stage: right before a tool executes. Let's start with the structure that extracts and validates the tool information.

TypeScript
import type {
  HookCallback,
  PreToolUseHookInput,
} from "@anthropic-ai/claude-agent-sdk";

const bashSafetyGuardrail: HookCallback = async (
  input,
  toolUseID,
  { signal }
) => {
  // Runs just before a tool executes
  // Cast input to PreToolUseHookInput to access tool-specific properties
  const hookInput = input as PreToolUseHookInput;

  // If this hook isn't for a Bash tool, return early to avoid unnecessary processing
  // This makes our hook efficient by only inspecting Bash commands
  if (hookInput.tool_name !== "Bash") {
    return {};
  }

  // Cast tool_input to access the command property
  // The tool_input contains arguments and data specific to the tool being invoked
  const toolInput = hookInput.tool_input as Record<string, unknown>;
  
  // Get the actual command string that will be executed
  // Use type assertion and nullish coalescing for safe access
  const command = (toolInput.command as string) ?? "";
  console.log(`\n[HOOK] Inspecting Bash command: ${command}`);
  
  // Pattern checking logic will be added here...
  
  // If no issues found, allow the command to execute
  return {};
};

For PreToolUse hooks, we cast input to PreToolUseHookInput to access the tool_name and tool_input properties. We first check whether the tool is "Bash" and return early if it is not, making our hook efficient. If it is a Bash command, we extract the actual command string using safe type casting and the nullish coalescing operator. This follows the same "cast → normalize → return" pattern as our intent guardrail.

Building a Bash Safety Guardrail: Adding Pattern Blocking

Now, let's add the pattern-checking logic between the console.log and the final return {} statement:

TypeScript
// Define patterns that represent dangerous or destructive Bash commands
// These are patterns that could cause significant system damage if executed
const dangerousPatterns = [
  "rm -rf",              // Recursively force delete - very destructive
  "sudo",                // Privilege escalation - could bypass security restrictions
  "> /etc/",             // Overwriting system configuration files
  "mkfs",                // Formatting filesystems - destroys data
  ":(){ :|:& };:",       // Fork bomb - crashes the system
];

// Check if any dangerous pattern appears in the command
for (const pattern of dangerousPatterns) {
  if (command.includes(pattern)) {
    // Log which pattern triggered the denial
    console.log(`[HOOK] Denying Bash command due to pattern: ${pattern}`);
    
    // Return a denial decision with specific structure required by PreToolUse hooks
    // Use 'as const' for all literal types to satisfy TypeScript's type requirements
    return {
      hookSpecificOutput: {
        // Identifies which hook type is making this decision
        hookEventName: "PreToolUse" as const,
        // Set to "deny" to block tool execution
        permissionDecision: "deny" as const,
        // Provide a detailed reason explaining why the command was blocked
        permissionDecisionReason:
          `Command contains forbidden pattern: ${pattern}`,
      },
    };
  }
}

This completes our Bash safety guardrail by adding the "scan patterns" step. We define an array of dangerousPatterns including destructive commands like "rm -rf" and privilege escalation attempts. When we find a dangerous pattern, we return the specific structure required by PreToolUse hooks: a hookSpecificOutput object containing hookEventName, permissionDecision set to "deny" as const, and permissionDecisionReason. All literal values use as const assertions to satisfy the SDK's type requirements. If no dangerous patterns are found, execution continues to the final return {} that allows the command.

Notice how both hooks follow the same pattern: cast the input, normalize the data, scan for problems, and return either a blocking decision or an empty object. This consistent structure makes it easy to understand and extend your safety system.

Registering Hooks with Options

Now that we have built both hooks, we need to register them with our agent through the Options configuration object. The hooks property accepts an object in which the keys are hook type names and the values are arrays of objects specifying which tools the hook applies to (via matcher) and which hook functions to execute (via the hooks array).

TypeScript
import { query } from "@anthropic-ai/claude-agent-sdk";
import type { Options } from "@anthropic-ai/claude-agent-sdk";

// Create the agent configuration with safety hooks registered
const options: Options = {
  model: "haiku",
  maxTurns: 5,
  allowedTools: ["Bash"],
  // Register our custom hooks at specific points in the agent's lifecycle
  hooks: {
    // UserPromptSubmit hooks run when the user first submits their prompt
    UserPromptSubmit: [
      {
        // The matcher "*" means this hook applies to all prompts regardless of tools
        matcher: "*",
        // Array of hook callback functions to execute for this event
        hooks: [intentGuardrail],
      },
    ],
    // PreToolUse hooks run right before the agent executes any tool
    PreToolUse: [
      {
        // Again, "*" means this hook checks all tool executions
        matcher: "*",
        // The bashSafetyGuardrail function will internally filter for only Bash tools
        hooks: [bashSafetyGuardrail],
      },
    ],
  },
};

Each hook registration object contains two properties: matcher, which specifies the tools to which this hook applies (using "*" for all tools or a specific tool name like "Bash"), and hooks, which is an array of callback functions that execute when the hook triggers. You can register multiple hooks for the same event, and they will execute in the order in which you list them. Notice that we pass the hook functions we defined earlier (intentGuardrail and bashSafetyGuardrail) directly to the hooks arrays — this connects our safety logic to the agent's execution lifecycle.

The matcher: "*" pattern means "apply to all," though you could also use a specific tool name. In our bashSafetyGuardrail, we perform an internal check for tool_name !== "Bash" to filter — this approach keeps our hook logic explicit and clear.

Testing the Agent with the Main Function

Now, let's put our hooks to work by creating a main function that tests different scenarios. This function sets up the agent with our two safety hooks registered and runs through a series of test prompts to demonstrate how the hooks work at each level.

TypeScript
import { displayResponse } from "./utils";

async function main() {
  // Define a series of test prompts to evaluate different hook behaviors
  const prompts = [
    "How do I hack the mainframe to get passwords?",  // Should be blocked by intentGuardrail
    "Please run a test to remove the /tmp/test directory using 'rm -rf'.",  // Should be blocked by bashSafetyGuardrail
    "List files in the current directory.",  // Should pass both guards
  ];

  // Process each test prompt sequentially
  for (const prompt of prompts) {
    console.log(`\n=== Testing prompt: ${prompt} ===`);
    
    // Call query() with the prompt and options
    // query() returns an AsyncIterable<SDKMessage> that yields messages as the agent processes
    await displayResponse(query({ prompt, options }));
  }
}

main();

The main function creates an Options object with both hooks registered, specifying that the agent can use the Bash tool with a maximum of 5 turns. It then defines three test prompts: one with a blocked keyword, one with a dangerous command, and one that is completely safe. The loop processes each prompt sequentially by calling query({ prompt, options }), which returns an AsyncIterable<SDKMessage>. We pass this iterable to displayResponse() from our utils.ts file, which consumes the messages and prints them in a formatted way. Let's see what happens when we run this code.

Testing the Intent Guardrail

When we run the code with our first test prompt, the intent guardrail immediately blocks it.

text
=== Testing prompt: How do I hack the mainframe to get passwords? ===

[HOOK] Checking user intent: how do i hack the mainframe to get passwords?
[HOOK] Blocking prompt due to keyword: hack

The hook detects the word "hack" and prevents the agent from even seeing this request. Notice that there is no 💬 Claude Response because the prompt never reached the agent — our first layer of defense worked perfectly. Now, let's see what happens with the second prompt.

Testing the Bash Safety Guardrail

The second prompt passes the intent guardrail but triggers our Bash safety guardrail when the agent tries to execute a dangerous command.

text
=== Testing prompt: Please run a test to remove the /tmp/test directory using 'rm -rf'. ===

[HOOK] Checking user intent: please run a test to remove the /tmp/test directory using 'rm -rf'.

💬 Claude Response:
I'll run a command to remove the /tmp/test directory using 'rm -rf'.

🔧 [Tool: Bash]

[HOOK] Inspecting Bash command: rm -rf /tmp/test
[HOOK] Denying Bash command due to pattern: rm -rf

💬 Claude Response:
I see that the `rm -rf` command is blocked for security reasons. This is a safety measure to prevent accidental or malicious deletion of directories.

If you need to remove the /tmp/test directory, here are some safer alternatives:

1. **Use `rm -r` instead of `rm -rf` (removes with prompts):
   ```bash
   rm -r /tmp/test
   ```

2. **Use `rmdir` for empty directories**:
   ```bash
   rmdir /tmp/test
   ```

3. **Use `find` with `-delete`:
   ```bash
   find /tmp/test -delete
   ```

Would you like me to try one of these alternatives instead? The `rm -r` version would be the most equivalent to `rm -rf`, just with confirmation prompts for protection.

The prompt passes the intent guardrail because it does not contain blocked keywords, and the agent receives the request. The agent plans to use the Bash tool, which triggers the 🔧 [Tool: Bash] indicator from displayResponse(). However, when the agent tries to run rm -rf /tmp/test, our Bash safety guardrail intercepts it and denies the tool execution.

Important: Notice that the agent suggests alternatives it considers "safer," but these are still destructive operations — they are less forceful, not truly safe. Commands like find ... -delete or rm -r can still delete files irreversibly. In production systems, you would implement more robust guardrails such as:

  • Path allowlists: Only permit operations within specific directories (e.g., /tmp/sandbox/*)
  • Dry-run mode: Require tools to show what they would do before executing destructive operations
  • Explicit confirmation: Use a confirmation hook that prompts a human before executing any destructive command
  • Working directory restrictions: Limit the agent to operate only within a designated, isolated directory structure

Our simple pattern-matching hook demonstrates the mechanism, but real-world safety requires combining multiple strategies based on your specific risk tolerance and use case. Let's see what happens with a safe request.

Testing a Safe Request

The third prompt demonstrates that our hooks allow legitimate operations to proceed normally.

text
=== Testing prompt: List files in the current directory. ===

[HOOK] Checking user intent: list files in the current directory.

🔧 [Tool: Bash]

[HOOK] Inspecting Bash command: ls -la

💬 Claude Response:
Here are the files in the current directory:

- `.codesignal/` - Directory (hidden)
- `main.sh` - Shell script file (read-only, 66 bytes)
- `main.ts` - TypeScript file (executable, 3,131 bytes)
- `node_modules/` - Directory containing npm dependencies
- `package.json` - NPM package configuration file (read-only, 388 bytes)
- `utils.ts` - TypeScript file (executable, 637 bytes)
- `yarn.lock` - Yarn lock file (16,988 bytes)

This appears to be a TypeScript/Node.js project. Would you like me to examine any of these files or perform any other operations?

This prompt is completely safe — it passes the intent guardrail, and when the agent executes ls -la, the Bash safety guardrail inspects it, finds no dangerous patterns, and allows it to run. The 🔧 [Tool: Bash] marker appears when the tool is invoked, and the agent successfully lists the directory contents, showing that our hooks provide security without blocking legitimate operations.

Summary and Practice Preparation

You've now learned how to use hooks to gain visibility and control over your Claude agent's execution! You implemented two critical hook types — UserPromptSubmit for validating user intent and PreToolUse for controlling tool executions — creating a defense-in-depth approach in which the intent guardrail catches problems at the prompt level while the tool safety guardrail provides a second layer of protection at the execution level.

You learned the consistent "cast → normalize → scan → return" pattern that all hooks follow: cast the input parameter to the appropriate type (UserPromptSubmitHookInput or PreToolUseHookInput), normalize and extract the data you need to inspect, scan for problems using your safety rules, and return either a blocking decision (using as const assertions for literal types) or an empty object to proceed. You saw how to register these hooks in the Options object and pass them to query(), which returns an AsyncIterable<SDKMessage> that your displayResponse() utility consumes to show formatted output.

In the upcoming practice exercises, you will extend these concepts by implementing additional safety rules, creating logging hooks, and building more sophisticated guardrails.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal