Tool-call bash hooks
Feature docs index · Repository README
Trusted local hooks can audit, enforce policy, send notifications, or inject context. They are disabled by default and run in the active agent cwd, including child cwd for inherited subagent hooks.
Hooks wrap built-in tools such as read, bash/shell, hash_edit, write, grep, find, subagents, and web. Message hooks can also run after complete assistant messages or reasoning summaries. Hooks are separate from the bash tool and do not recurse.
Each hook receives JSON on stdin. Stdout and stderr remain local and cannot rewrite tool arguments or results. Provider context injection requires a successful after hook with explicit enablement. Hook processes are bounded and cleaned up after success; do not start background jobs that must outlive them.
Quick start
Add this to ~/.magi-code/settings.json under automation.hooks:
{
"automation": { "hooks": {
"enabled": true,
"show_in_tui": true,
"injected_content": {
"show_in_transcript": false,
"show_in_activity_tree": false,
"style": "content"
},
"payload": "redacted",
"failure_policy": "warn",
"before_tool": [
{
"label": "audit-before",
"command": "./scripts/magi-hooks/audit-before.sh"
}
],
"after_tool": [
{
"label": "audit-after",
"command": "./scripts/magi-hooks/audit-after.sh",
"failure_policy": "ignore"
}
]
}
}}
Create the configured scripts in the launch repository. Both can use this body:
#!/usr/bin/env bash
set -euo pipefail
mkdir -p .magi-hooks
cat >> .magi-hooks/tool-hooks.jsonl
printf '\n' >> .magi-hooks/tool-hooks.jsonl
Make them executable. Prefer reviewed script paths to inline shell; configured commands must pass the preflight below. Audit files may contain sensitive local data, especially with full payloads.
Configuration schema
Global settings under automation.hooks:
| Field | Type | Default | Bounds / values | Meaning |
|---|---|---|---|---|
enabled | boolean | false | true or false | Master switch. false makes hooks inert. |
show_in_tui | boolean | false | true or false | Display-only Mission Control opt-in. Shows matching hook start/success/failure activity rows when hooks are enabled; does not enable hook execution. Does not control provider context injection visibility. |
injected_content.show_in_transcript | boolean | false | true or false | Display provider context injection rows in Mission Control transcript. Visibility only; does not enable provider context injection. |
injected_content.show_in_activity_tree | boolean | false | true or false | Display provider context injection rows in Mission Control activity tree under the related tool/message activity when available, or as root rows otherwise. Visibility only; does not enable provider context injection. |
injected_content.style | string | "content" | "content", "metadata" | Transcript display style for injected content. content shows redacted/truncated injected content when the corresponding display setting is enabled; metadata hides injected content and shows only label, status, item count, and byte count. |
payload | string | "redacted" | "redacted", "full" | Default payload mode for hooks. |
timeout_seconds | integer | 5 | 1 to 60 | Per-hook process timeout. Timeout is hook failure. |
stdout_max_bytes | integer | 8192 | 1 to 65536 | Max captured stdout bytes. Exceeding cap is hook failure; output is suppressed in diagnostics. |
stderr_max_bytes | integer | 8192 | 1 to 65536 | Max captured stderr bytes. Exceeding cap is hook failure; output is suppressed in diagnostics. |
provider_context_injection | boolean | false | true or false | Allow successful after_tool, after_assistant, and after_reasoning hooks to parse stdout context_items as provider-visible user messages. |
provider_context_max_bytes | integer | 4096 | 1 to 16384 | Max total UTF-8 bytes of injected context content per hook. |
failure_policy | string | "warn" | "ignore", "warn", "block", "fail" | Default handling for hook failures. block is valid only for before_tool. |
before_tool | array | [] | hook definitions | Hooks run before matching tool dispatch. |
after_tool | array | [] | hook definitions | Hooks run after matching tool dispatch. failure_policy: "block" is rejected here. |
after_assistant | array | [] | hook definitions | Hooks run after the assistant produces a complete message. failure_policy: "block" is rejected. include_tools/exclude_tools do not apply. |
after_reasoning | array | [] | hook definitions | Hooks run after a complete reasoning summary. failure_policy: "block" is rejected. include_tools/exclude_tools do not apply. |
Per-hook fields apply to all four phase arrays:
| Field | Type | Default | Meaning |
|---|---|---|---|
label | string | "hook" | Local diagnostic label. Empty labels become "hook". |
command | string | required | Shell command to run after safety preflight. |
payload | string | global automation.hooks.payload | Override payload mode for this hook. |
failure_policy | string | global automation.hooks.failure_policy | Override failure policy for this hook. block only works in before_tool. |
timeout_seconds | integer | global automation.hooks.timeout_seconds | Override timeout, 1 to 60. |
stdout_max_bytes | integer | global automation.hooks.stdout_max_bytes | Override stdout cap, 1 to 65536. |
stderr_max_bytes | integer | global automation.hooks.stderr_max_bytes | Override stderr cap, 1 to 65536. |
provider_context_injection | boolean | global automation.hooks.provider_context_injection | Per-hook context-injection override for after phases. No runtime effect for before_tool. |
provider_context_max_bytes | integer | global automation.hooks.provider_context_max_bytes | Per-hook context byte limit, 1 to 16384. |
include_tools | string array | [] | If non-empty, hook runs only for exact tool names listed. Prefer canonical names such as read, bash, hash_edit, write, grep, find, subagents, web. Has no effect for after_assistant / after_reasoning. |
exclude_tools | string array | [] | Hook does not run for exact tool names listed. Applied after include_tools. Has no effect for after_assistant / after_reasoning. |
Hook stdin JSON has a hard 1 MiB cap. Redacted payloads are summarized before spawn. When possible, trusted payload: "full" bodies above this cap move to an ephemeral local payload_ref file. Otherwise stdin remains valid JSON with payload.status: "omitted_too_large" and bounded summaries.
A hook that exits or stops reading stdin early may cause BrokenPipe during payload-writer cleanup. This is normal and does not affect classification. Exit status, timeout, cleanup warnings, and output limits still determine the result. Only other stdin write errors or envelope failures count as stdin failures.
Use include_tools: ["bash", "write", "hash_edit"] to select tools or exclude_tools: ["read", "grep"] to skip them. Matching is exact, and exclusions win. For a before-only policy gate, set failure_policy: "block" on that hook instead of globally.
Lifecycle
For each provider-requested built-in tool call:
- magi-code records the tool call in the session.
- Matching
before_toolhooks run in array order. Each matching hook writes sanitized local-onlyhook_lifecyclestartedand terminal (successorfailed) records when sessions are enabled. - If all before hooks continue, the target tool runs.
- magi-code records the tool result in the session.
- Matching
after_toolhooks run in array order with both request and result payload fields. Each matching hook writes sanitizedhook_lifecyclerecords after the tool result. If provider context injection is enabled for a successful after hook, magi-code parses stdout JSON and appends validated user-role context items after the tool result for the next provider request. - Hook warnings are displayed locally and recorded as local-only
hook_diagnosticsession events. - Provider continuation receives the normal tool result, or a sanitized blocked-tool result if a before hook blocked the tool.
Successful session JSONL order is tool_call → before-hook hook_lifecycle started/terminal → tool_result → after-hook hook_lifecycle started/terminal → optional local-only hook_context_injection audit → optional provider-visible provider_context_item. Lifecycle and audit persistence are best-effort local telemetry, never provider-visible output. Inherited subagent records go to child session JSONL, not parent JSONL.
For message-phase hooks:
after_assistanthooks fire afterSessionEventKind::AssistantOutputis recorded. Payloadtool.nameis"assistant"andrequest.textholds the full assistant message text.after_reasoninghooks fire afterSessionEventKind::ReasoningSummaryis recorded. Payloadtool.nameis"reasoning"andrequest.textholds the full reasoning summary text.- Both phases record
hook_lifecycleandhook_context_injectionaudit events the same way asafter_tool.
hook_lifecycle uses an allowlist: phase, label, target_tool, status, policy, category, target_ran, tool_call_id, activity_id, hook_index, elapsed_ms, and message. It never stores hook command text, stdin JSON, stdout, stderr, tool arguments/results, environment values, credentials, auth codes, bearer headers, or account ids.
The local-only hook_context_injection audit also uses an allowlist: schema_version, phase, label, target_tool, status, item_count, byte_count, max_bytes, and hook_index. It never stores raw stdout/stderr, hook stdin, tool arguments/results, or injected content.
Hook failure categories are exit, timeout, runner, stdin, and output-limit.
Mission Control visibility
All display settings default off and do not enable hook execution or context injection:
automation.hooks.show_in_tuishows live hook rows under the related tool or child task: phase, label, tool, running/success/failure, and relevant category/policy. Success does not create ahook_diagnosticevent.automation.hooks.injected_content.show_in_transcriptshows a separate injection row;show_in_activity_treeshows a related child row, or a root row without a parent. These controls are independent ofshow_in_tuiand each other.injected_content.styledefaults tocontent(redacted/truncated text).metadatashows only label, status, item count, and byte count. Neither changes what the provider receives.
hook_lifecycle events persist even with live rows hidden. Resumed history shows recorded hooks without rerunning commands; injection history follows its display settings.
Payloads
Every hook receives one JSON object on stdin using schema magi-code.tool_hook with schema_version: 1. Top-level fields include phase, hook, tool, cwd, payload_mode, request, and, for after hooks, result.
Before payload shape:
{
"schema": "magi-code.tool_hook",
"schema_version": 1,
"phase": "before_tool",
"hook": {
"label": "audit-before",
"index": 0,
"failure_policy": "warn",
"payload_mode": "redacted"
},
"tool": { "name": "bash", "call_id": "call_abc123" },
"context": {
"cwd": "/Users/example/project",
"session_id": "session-id-if-enabled",
"session_path": "/Users/example/.magi-code/sessions/session-id-if-enabled.jsonl",
"provider_id": "openai-codex",
"model_id": "gpt-5",
"agent_id": "tars",
"invocation_mode": "mission_control",
"turn_id": "turn-0",
"message_id": "tool-tool-call-0",
"subagent": false,
"timestamp": "2026-05-26T00:00:00Z"
},
"affected_paths": [],
"payload": {
"inline": true,
"status": "inline",
"data": {
"request": {
"command": { "redacted": true, "kind": "command", "bytes": 42 },
"timeout": 30
}
}
},
"payload_ref": null,
"cwd": "/Users/example/project",
"payload_mode": "redacted",
"request": {
"command": { "redacted": true, "kind": "command", "bytes": 42 },
"timeout": 30
}
}
After-tool payloads use the same envelope with phase: "after_tool" and a result in both payload.data and the top level. Results contain tool_name, success, content, and metadata; redacted content is "<redacted:tool-output>". A write target can appear as { "path": "notes.txt", "kind": "write_target", "source": "request.path" } in affected_paths.
Context metadata availability:
| Field | Source / null behavior |
|---|---|
session_id, session_path | Active session when persistence is enabled; otherwise null. Subagents use child session values. |
provider_id, model_id | Runtime provider selection; no credentials or account ids. |
agent_id | Selected primary-agent id or subagent task identity/label when available; otherwise null. |
invocation_mode | One of print, mission_control, subagent. Set by caller path, not inferred from prompt text. The internal print label is not a CLI conversation mode. |
turn_id, message_id | Local run-generated correlation ids. Before/after hooks for one tool call share message_id. |
subagent | true only for child subagent runs. |
timestamp | Hook payload construction time in UTC. |
affected_paths is a bounded allowlisted array, not a comprehensive inventory of effects. read.paths uses kind: "read_target" and source: "request.paths". grep/ffgrep, find/fffind, and list_files use their path field with kind: "read_target" and source: "request.path"; write.path uses kind: "write_target" and source: "request.path". Each tool's capabilities.tools.<tool>.absolute_paths policy controls absolute paths outside cwd (aliases use the canonical tool's policy). Relative and symlink-resolved escapes forbidden by that policy, overlong paths, and paths that cannot be safely resolved are omitted. Bash command text, stdout/stderr, model prose, and arbitrary strings are never parsed for paths.
payload: "redacted" summarizes or removes sensitive material before the hook sees it:
- command bodies:
command,cmd - file contents:
content; hash-edit patch text:input - captured output:
stdout,stderr,output - credential-shaped keys: API keys, tokens, bearer/OAuth values, auth codes, account ids, and similar fields
- credential-shaped substrings inside strings
payload: "full" sends original tool request JSON and full tool result JSON to the hook process. Use it only for trusted local scripts because it can expose command bodies, file contents, tool output, and secrets already present in tool inputs/results.
Large full payload behavior:
- Stdin JSON remains valid and under the 1 MiB cap.
- If full request/result bodies make the envelope too large, magi-code writes those bodies to a runtime-owned local
payload_ref.pathand sends only context plus bounded summaries on stdin. payload_refincludespath,bytes,payload_mode,media_type: "application/json", anddigest_sha256.- Payload-ref files are private local artifacts (
0700directories and0600files on Unix where supported) and are deleted after the hook process exits by default. - If spill capture cannot be created or a hard cap is exceeded, hooks receive
payload.status: "omitted_too_large"; top-levelrequest/resultcontain bounded summaries withoriginal_bytes,omitted_fields, andreasonrather than truncated JSON or raw large bytes. payload_refpaths, spill bytes, hook stdin, stdout/stderr, and hook diagnostics are local-only. They are excluded from provider requests, provider continuations, context-cache material, and parent subagent summaries.
Provider context injection
Provider context injection is disabled by default. Enable it globally or per after_tool / after_assistant / after_reasoning hook when a trusted local script should add bounded context to the next provider request:
{
"automation": {
"hooks": {
"enabled": true,
"provider_context_injection": false,
"provider_context_max_bytes": 4096,
"after_tool": [
{
"label": "memory-after",
"command": "./scripts/magi-hooks/memory-after.sh",
"provider_context_injection": true,
"provider_context_max_bytes": 4096
}
]
}
}
}
Successful enabled after_tool hooks may print exactly this JSON shape on stdout:
{
"context_items": [
{ "role": "user", "content": "Relevant bounded memory for next provider call." }
]
}
Rules:
- Only
after_tool,after_assistant, andafter_reasoningcan inject.before_toolstdout never injects. - The hook process must succeed. Non-zero exit, timeout, runner error, stdin error, or output-limit failure injects nothing.
- Only
role: "user"is supported in v1. Injected items become normal provider-visible user messages, ordered after the tool result (forafter_tool) or after the assistant/reasoning message (forafter_assistant/after_reasoning). - Multiple matching after hooks append in config order; multiple
context_itemsappend in JSON order. provider_context_max_bytescounts total UTF-8 content bytes per hook. Default4096, max16384. Setstdout_max_bytesat least as high as expected JSON stdout too; default stdout cap is8192, so 16KiB context needs a larger stdout cap or it fails ashook_failed/output-limit.- Validation is all-or-nothing per hook. Invalid JSON, missing/wrong
context_items, unsupported role, empty/non-string content, or over-limit content injects nothing. hook_context_injectionrecords sanitized local audit status such assuccess,invalid_json,invalid_shape,unsupported_role,empty_content,over_limit, orhook_failed.- Child subagent injections stay in child conversation/session. Parent provider requests and parent
subagentsresults do not receive child injected context unless the child final answer intentionally includes it.
after_assistant and after_reasoning phases
Configure these arrays like after_tool; tool filters do not apply. Payloads use tool.name: "assistant" or "reasoning", with call ids such as assistant-turn-0 or reasoning-turn-0. request.text holds the full completed message or reasoning summary, including in payload.data.request. affected_paths is empty and result is absent/null. Both use the same context_items stdout contract.
Failure policies
| Policy | Before hook failure | After hook failure | after_assistant / after_reasoning failure |
|---|---|---|---|
ignore | Continue silently. | Continue silently. | Continue silently. |
warn | Continue and show sanitized local warning. Default. | Continue and show sanitized local warning. Default. | Continue and show sanitized local warning. Default. |
block | Prevent target tool. Assistant receives sanitized blocked result. | Not allowed. after_tool cannot block because target already ran; config using it is rejected. | Not allowed. Same as after_tool; config using it is rejected. |
fail | Stop the turn before target tool runs. | Preserve original tool result locally, then fail the turn before provider continuation. | Fail the turn before provider continuation. |
A hook failure means non-zero exit, timeout, runner/preflight error, stdin write error, or stdout/stderr exceeding configured capture limits.
Command safety and cwd
Hook commands run through the platform shell with cwd set to the agent cwd and a sanitized environment:
- On Unix:
/bin/bash --noprofile --norc -c <command> - On Windows: PowerShell (
pwsh, thenpowershell.exe) with-NoProfile -NonInteractive -Command <command>
Before spawning, magi-code rejects hook command strings containing shell expansion characters that make cwd-scoping ambiguous:
$ ~ ` { }
It also rejects cd, parent-directory path components such as .., and absolute paths when capabilities.tools.bash.absolute_paths is false. With the default capabilities.tools.bash.absolute_paths: true, absolute hook command paths may pass preflight, but script paths inside the repository are easier to review and move across machines.
Use a simple command such as ./scripts/magi-hooks/audit-jsonl.sh or python3 scripts/magi-hooks/audit.py. Executable scripts need a shebang and executable permission. Commands such as echo "$PWD", cd scripts && ./hook.sh, ../hooks/audit.sh, ~/hooks/audit.sh, and ./scripts/{audit,notify}.sh fail preflight.
Put complex shell or Python logic inside the reviewed script. Command-string restrictions do not restrict the script body and are not a sandbox. See Security notes for the exact environment profile.
Script examples
This Python audit records only phase and tool name rather than whole payloads:
#!/usr/bin/env python3
import json
import pathlib
import sys
payload = json.load(sys.stdin)
pathlib.Path(".magi-hooks").mkdir(exist_ok=True)
with open(".magi-hooks/python-hooks.jsonl", "a", encoding="utf-8") as handle:
handle.write(json.dumps({"phase": payload["phase"], "tool": payload["tool"]["name"]}) + "\n")
For validators, opt in to payload: "full" only for scripts you trust. A before hook with failure_policy: "block" can reject a request by exiting nonzero. Handle payload_ref and omitted_too_large explicitly; a pattern check against a missing or summarized body is not a complete safety check.
Privacy and sessions
- Redacted mode is default because hook payloads can contain command bodies, file contents, and tool output.
- Full mode is local only, but local scripts can write, print, forward, or leak what they receive. Use full mode only with scripts you control.
- Hook diagnostics are sanitized before terminal display and before session persistence.
- Hook stdout/stderr contents are not shown in diagnostics; failures report status/category with stdout/stderr suppressed.
- Session JSONL may include local-only
hook_diagnostic,hook_lifecycle, andhook_context_injectionaudit events. They are not replayed to providers during session continuation and are excluded from context/cache material. - Opt-in injected context is persisted separately as provider-visible
provider_context_itemsession events so resumed sessions replay what the provider saw. - Replayed Mission Control history reads persisted hook lifecycle rows from JSONL and does not rerun hook commands.
- A
before_toolblock creates a provider-visible tool result saying the tool was blocked by local hook policy. Hook label and detailed script output are redacted from that result. - Inherited subagent hook internals stay in child session JSONL. Parent
subagentsoutput and parent provider continuation receive only child task summaries and sanitized hook-policy failure text. - Subagents may run concurrently, so inherited hook commands may also run concurrently in different child cwd values. Hook scripts that touch shared external files, sockets, or services must provide their own locking.
Troubleshooting hooks
| Symptom | Likely cause | Fix |
|---|---|---|
| Hook never runs | automation.hooks.enabled is false, arrays are empty, or filters do not match tool name. | Enable hooks and check exact names in include_tools / exclude_tools. |
| Hooks run but no successful live rows appear in Mission Control | automation.hooks.show_in_tui is false, Mission Control is not active, or no matching hook ran. Session hook_lifecycle records still persist when hooks run and sessions are enabled. | Set "show_in_tui": true under automation.hooks for live rows, keep automation.hooks.enabled: true, and run magi-code. |
Settings fail to load with block error | after_tool, after_assistant, or after_reasoning hook inherits or sets failure_policy: "block". | Set that after hook to ignore, warn, or fail. |
| Command rejected before spawn | Command string contains $, ~, backticks, {}, cd, .., or disallowed absolute path. | Move logic into a script and configure a simple script path. |
| Hook times out | Script exceeds timeout_seconds or waits for interactive input. | Read stdin once, avoid prompts, increase timeout up to 60. |
Hook fails with stdin | Non-BrokenPipe write error or envelope failure. Closing stdin early with BrokenPipe alone is ignored. | Read stdin once; inspect payload.status, payload_ref, and sanitized diagnostics. Large full payloads normally spill or use omitted_too_large. |
Hook fails with output-limit | Script printed more than stdout/stderr caps. | Write large logs to files; keep stdout/stderr short; raise cap up to 65536. |
| Script cannot find files | Hooks run in the active agent cwd. Inherited subagent hooks run in the child task cwd. | Use paths relative to the task cwd, or reviewed absolute paths when policy permits. |
| Script sees redacted content | Default payload mode is redacted. | Use per-hook payload: "full" only for trusted validators. |
| Assistant did not see hook warning | Hook diagnostics are local-only. | Inspect terminal/TUI transcript or session JSONL; use block/fail policy when provider-visible behavior must change. |