← Back to Index

Lesson 3

Hooks

What Hooks Are

Hooks let you automate agent actions in response to events - without you typing a prompt. Something happens in the IDE (a file is saved, a tool is about to run, a session starts), and a hook fires: running a shell command, injecting a prompt, or blocking an action.

If steering is "what Kiro knows," hooks are "what Kiro does automatically."

Where Hooks Live

Hook files are JSON, stored at:

.kiro/hooks/<id>.json

Each file can contain multiple hooks. The <id> is a short dashed identifier you choose (e.g. lint-on-save, security-checks).

The Hook Schema

Here's the complete structure:

{
  "version": "v1",
  "hooks": [
    {
      "name": "Human-readable name",
      "trigger": "PostFileSave",
      "matcher": "\\.(ts|tsx)$",
      "action": {
        "type": "command",
        "command": "npm run lint"
      }
    }
  ]
}

A hook has four parts:

FieldWhat it does
nameDisplay name (shown in the Agent Hooks panel)
triggerThe event that fires this hook (PascalCase)
matcherOptional regex to filter which instances of the trigger fire the hook
actionWhat to do: run a command or inject an agent prompt

What Are "Tools"?

Tools are the actions the agent can perform - the mechanism by which Kiro actually does things rather than just generating text. Every time Kiro reads a file, edits code, runs a command, or searches the web, it's invoking a tool. There are two sources:

Built-in tools - always available:

MCP tools - provided by MCP servers you've configured. These extend the agent with external capabilities (databases, APIs, documentation, etc.). Covered in Lesson 5.

When hooks reference "tools" - PreToolUse, PostToolUse, the matcher categories like @builtin and @mcp - this is what they mean. Every action Kiro takes on your behalf is a tool invocation that hooks can intercept.

All Triggers (Complete List)

There are exactly 10 trigger events:

TriggerWhen it firesMatcher tests against
SessionStartA new session begins -
UserPromptSubmitYou send a message -
PreToolUseBefore the agent invokes a toolTool name
PostToolUseAfter the agent invokes a toolTool name
PreTaskExecBefore a spec task is set to in_progress -
PostTaskExecAfter a spec task is set to completed -
PostFileSaveWhen you save a fileFile path
PostFileCreateWhen you create a new fileFile path
PostFileDeleteWhen you delete a fileFile path
StopWhen the agent finishes its turn -

Triggers marked " - " in the matcher column always fire (matcher is ignored).

The Two Action Types

1. Command - run a shell command

{
  "type": "command",
  "command": "npm run lint --fix"
}

Runs a shell command. The command receives JSON on stdin with session context (useful for advanced hooks that need to know what file triggered them, etc.).

2. Agent - inject a prompt

{
  "type": "agent",
  "prompt": "Before writing this file, verify it follows our error handling patterns: all async functions must have try/catch with structured logging."
}

Appends a text prompt to the model context. Think of this as event-triggered steering - it's like an always-on steering file that only activates when a specific event fires.

Key difference: A command action runs outside Kiro (in your shell). An agent action runs inside Kiro (injected into the model's context). Use command for tooling (lint, format, test). Use agent for guiding Kiro's behaviour.

Exit Codes (Command Actions)

When a command hook runs, its exit code determines what happens next:

Exit codeMeaningEffect
0Successstdout is forwarded to the agent (for SessionStart, UserPromptSubmit, PreToolUse)
2BlockThe triggering action is prevented (only works for PreToolUse, UserPromptSubmit, PreTaskExec). stderr is forwarded.
Any otherSilent failureHook is considered failed. No block, no forwarded output.

You don't "read" exit codes - you produce them. Your shell command finishes with an exit code, and Kiro reacts to it. You control this by writing a command or script that exits with the appropriate code based on your logic.

How exit codes work in shell

Every shell command produces an exit code. 0 means success, anything else means failure. You control it with:

Example: Block writes to files containing "DO NOT EDIT"

{
  "version": "v1",
  "hooks": [{
    "name": "Protect generated files",
    "trigger": "PreToolUse",
    "matcher": "fs_write|str_replace",
    "action": {
      "type": "command",
      "command": "bash -c 'INPUT=$(cat); FILE=$(echo \"$INPUT\" | grep -o '\"path\":\"[^\"]*\"' | head -1 | cut -d'\"' -f4); if [ -f \"$FILE\" ] && head -5 \"$FILE\" | grep -q \"DO NOT EDIT\"; then echo \"This file is auto-generated and should not be edited directly\" >&2; exit 2; else exit 0; fi'"
    }
  }]
}

What this does: reads the JSON input from stdin (which contains the tool parameters including the file path), checks if the target file contains "DO NOT EDIT" in its first 5 lines, and exits with code 2 (block) if it does. The stderr message is forwarded to the agent so it understands why the write was blocked.

Example: Forward additional context on session start

{
  "version": "v1",
  "hooks": [{
    "name": "Load project status",
    "trigger": "SessionStart",
    "action": {
      "type": "command",
      "command": "cat .kiro/project-status.txt"
    }
  }]
}

Since cat exits 0 on success, its stdout (the file contents) is forwarded to the agent as additional context at the start of every session.

Example: Block prompts that mention production deployment

{
  "version": "v1",
  "hooks": [{
    "name": "Block prod deploy prompts",
    "trigger": "UserPromptSubmit",
    "action": {
      "type": "command",
      "command": "bash -c 'INPUT=$(cat); if echo \"$INPUT\" | grep -qi \"deploy.*prod\"; then echo \"Production deployments must go through the CI pipeline\" >&2; exit 2; else exit 0; fi'"
    }
  }]
}

For UserPromptSubmit hooks, session context (including the user's message) is passed as JSON on stdin. This hook reads it, checks for production deployment mentions, and exits with code 2 (block) if found.

Example: Lint check that doesn't block (informational)

{
  "version": "v1",
  "hooks": [{
    "name": "Lint report",
    "trigger": "PostFileSave",
    "matcher": "\\.(ts|tsx)$",
    "action": {
      "type": "command",
      "command": "bash -c 'INPUT=$(cat); FILE=$(echo \"$INPUT\" | jq -r .filePath // empty); npx eslint --format compact \"$FILE\" || true'"
    }
  }]
}

The hook reads the file path from the stdin JSON payload. The || true ensures the command always exits 0, even if eslint finds errors. Since PostFileSave can't block anything, the exit code here just determines whether the output is forwarded. Using || true means lint output is always sent to the agent (which may then offer to fix the issues).

The stdin JSON: For PreToolUse and PostToolUse hooks, the command receives JSON on stdin containing session context and the tool invocation parameters. This is how your script knows which file is being written to, which command is being run, etc. Parse it with jq, grep, or whatever your script prefers.
Ad

PreToolUse: The Permission Hook

PreToolUse deserves special attention. It fires before the agent uses a tool, and the matcher regex tests against the tool name. You can target specific tools or use built-in categories:

Matcher valueTargetsExample use case
fs_write|str_replace|fs_appendSpecific built-in tools by nameGate all file writes
execute_bashA single specific toolReview shell commands before running
readAll built-in file read toolsLog which files the agent reads
writeAll built-in file write toolsEnforce code standards on writes
shellAll built-in shell toolsBlock dangerous commands
webAll built-in web toolsRestrict external fetches
specAll built-in spec toolsAdd context before spec generation
@builtinAll built-in tools (any category)Universal gate on built-in actions
@mcpAll MCP tools (any server)Gate all external integrations
@mcp.*sql.*MCP tools with "sql" in the nameGate database-related MCP tools
@mcp.*github.*MCP tools with "github" in the nameGate GitHub operations
@powersAll Powers toolsGate tools exposed by installed powers
*Everything (built-in + MCP + powers)Universal gate on all agent actions

Prefixes starting with @ are matched by regex, so you can write patterns like @mcp.*sql.* to narrow down to specific MCP tools by name. You can combine multiple tool names with | (regex OR).

Note on category matchers: The word-based categories (read, write, shell, web, spec) and the @-prefixed categories (@builtin, @mcp, @powers) may behave differently between the IDE and CLI. The @-prefixed categories are well-documented across both. If a word-based category doesn't work as expected in the IDE, try the specific tool names with regex OR instead (e.g. fs_write|str_replace|fs_append).

Concrete examples of PreToolUse hooks

Block all MCP tools in a specific project

{
  "version": "v1",
  "hooks": [{
    "name": "No MCP tools",
    "trigger": "PreToolUse",
    "matcher": "@mcp",
    "action": {
      "type": "command",
      "command": "echo 'MCP tools are disabled in this workspace' >&2; exit 2"
    }
  }]
}

Require confirmation before running any shell command

{
  "version": "v1",
  "hooks": [{
    "name": "Confirm shell commands",
    "trigger": "PreToolUse",
    "matcher": "shell",
    "action": {
      "type": "command",
      "command": "echo '{\"hookSpecificOutput\":{\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"Shell command requires confirmation\"}}'"
    }
  }]
}

Add instructions before any file write (agent action type)

{
  "version": "v1",
  "hooks": [{
    "name": "Write standards",
    "trigger": "PreToolUse",
    "matcher": "write",
    "action": {
      "type": "agent",
      "prompt": "Before writing: ensure the file has appropriate imports sorted, no unused variables, and follows the project's naming conventions."
    }
  }]
}

Gate database MCP tools with a script that checks branch

{
  "version": "v1",
  "hooks": [{
    "name": "Block DB writes on main",
    "trigger": "PreToolUse",
    "matcher": "@mcp.*sql.*",
    "action": {
      "type": "command",
      "command": "bash -c 'BRANCH=$(git rev-parse --abbrev-ref HEAD); if [ \"$BRANCH\" = \"main\" ]; then echo \"Cannot use database tools on main branch\" >&2; exit 2; fi; exit 0'"
    }
  }]
}

Log all tool usage (non-blocking)

{
  "version": "v1",
  "hooks": [{
    "name": "Audit log",
    "trigger": "PreToolUse",
    "matcher": "*",
    "action": {
      "type": "command",
      "command": "bash -c 'cat >> .kiro/tool-audit.log; exit 0'"
    }
  }]
}

This reads the stdin JSON (tool name + parameters) and appends it to an audit log. Exits 0 so it never blocks - purely informational.

A PreToolUse command hook can also return a special JSON response on stdout to prompt the user for confirmation:

{"hookSpecificOutput":{"permissionDecision":"ask","permissionDecisionReason":"This writes to a production config file"}}

When permissionDecision is "ask", the user gets a confirmation dialog before the tool proceeds.

Practical Examples

Lint TypeScript on save

{
  "version": "v1",
  "hooks": [{
    "name": "Lint on Save",
    "trigger": "PostFileSave",
    "matcher": "\\.(ts|html)$",
    "action": { "type": "command", "command": "bash -c 'FILE=$(cat | jq -r .filePath); npx ng lint --files \"$FILE\"'" }
  }]
}

Remind the agent about error handling before writing files

{
  "version": "v1",
  "hooks": [{
    "name": "Error handling reminder",
    "trigger": "PreToolUse",
    "matcher": "fs_write|str_replace",
    "action": {
      "type": "agent",
      "prompt": "Before writing: ensure all async functions use try/catch with our structured logger. Never swallow errors silently."
    }
  }]
}

Run tests after the agent finishes a spec task

{
  "version": "v1",
  "hooks": [{
    "name": "Test after task",
    "trigger": "PostTaskExec",
    "action": { "type": "command", "command": "npm test" }
  }]
}

Block writes to production config without confirmation

{
  "version": "v1",
  "hooks": [{
    "name": "Protect prod config",
    "trigger": "PreToolUse",
    "matcher": "fs_write|str_replace|fs_append",
    "action": {
      "type": "command",
      "command": "echo '{\"hookSpecificOutput\":{\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"This may modify a production configuration file\"}}'"
    }
  }]
}

Creating Hooks: Two Methods

You can create hooks in two ways:

  1. Ask Kiro to create one - describe what you want and Kiro uses the createHook tool to write the JSON file for you.
  2. Use the UI - open the command palette (Cmd+Shift+P) and search for "Open Kiro Hook UI", or look for the "Agent Hooks" section in the Kiro explorer panel.

You can also write the JSON file manually in .kiro/hooks/, but the above methods handle the schema for you.

Hooks vs Steering: When to Use Which

Use steering when...Use hooks when...
The instruction applies broadlyThe action should fire on a specific event
You want persistent contextYou want to run external tooling (lint, test, format)
The rule is informationalThe rule needs enforcement (blocking)
You control "what Kiro knows"You control "what Kiro does automatically"

Your Tangible Win

You can now design hooks that automate your workflow - from simple lint-on-save to security gates that block dangerous operations. You know the complete trigger set, both action types, exit code semantics, and when to choose hooks over steering.

Quiz

You want to prevent the agent from ever deleting files in your production directory. Which trigger and exit code?

PostFileDelete with exit code 0
PreToolUse with exit code 2
PostToolUse with exit code 2

You want the agent to always consider accessibility when writing JSX components. What action type?

agent (inject a prompt reminding about accessibility)
command (run an accessibility checker)
Either - they do the same thing

A command hook exits with code 5. What happens?

The triggering action is blocked
stdout is forwarded to the agent
Silent failure - nothing happens, no block