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:
| Field | What it does |
|---|---|
name | Display name (shown in the Agent Hooks panel) |
trigger | The event that fires this hook (PascalCase) |
matcher | Optional regex to filter which instances of the trigger fire the hook |
action | What 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:
fs_write- create/overwrite a filestr_replace- edit part of a filefs_append- append to a fileread_file- read a fileexecute_bash- run a shell commandgrep_search- search file contentsfile_search- find files by nameweb_fetch- fetch a URLremote_web_search- search the web- ...and others (semantic rename, smart relocate, list directory, etc.)
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:
| Trigger | When it fires | Matcher tests against |
|---|---|---|
SessionStart | A new session begins | - |
UserPromptSubmit | You send a message | - |
PreToolUse | Before the agent invokes a tool | Tool name |
PostToolUse | After the agent invokes a tool | Tool name |
PreTaskExec | Before a spec task is set to in_progress | - |
PostTaskExec | After a spec task is set to completed | - |
PostFileSave | When you save a file | File path |
PostFileCreate | When you create a new file | File path |
PostFileDelete | When you delete a file | File path |
Stop | When 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.
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 code | Meaning | Effect |
|---|---|---|
0 | Success | stdout is forwarded to the agent (for SessionStart, UserPromptSubmit, PreToolUse) |
2 | Block | The triggering action is prevented (only works for PreToolUse, UserPromptSubmit, PreTaskExec). stderr is forwarded. |
| Any other | Silent failure | Hook 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:
exit 0- explicitly exit with successexit 2- explicitly exit with "block"- A command's natural exit code - e.g.
grepexits 0 if it finds a match, 1 if it doesn't
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).
jq, grep, or whatever your script prefers.
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 value | Targets | Example use case |
|---|---|---|
fs_write|str_replace|fs_append | Specific built-in tools by name | Gate all file writes |
execute_bash | A single specific tool | Review shell commands before running |
read | All built-in file read tools | Log which files the agent reads |
write | All built-in file write tools | Enforce code standards on writes |
shell | All built-in shell tools | Block dangerous commands |
web | All built-in web tools | Restrict external fetches |
spec | All built-in spec tools | Add context before spec generation |
@builtin | All built-in tools (any category) | Universal gate on built-in actions |
@mcp | All MCP tools (any server) | Gate all external integrations |
@mcp.*sql.* | MCP tools with "sql" in the name | Gate database-related MCP tools |
@mcp.*github.* | MCP tools with "github" in the name | Gate GitHub operations |
@powers | All Powers tools | Gate 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).
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:
- Ask Kiro to create one - describe what you want and Kiro uses the
createHooktool to write the JSON file for you. - 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 broadly | The action should fire on a specific event |
| You want persistent context | You want to run external tooling (lint, test, format) |
| The rule is informational | The 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.