magi-code 0.77.1

Repository-aware CLI coding agent for terminal work
Documentation
# Tool-call bash hooks

[Feature docs index](README.md) ยท [Repository README](../../README.md)

Use trusted local hooks for auditing, policy checks, notifications, or context injection. Hooks are disabled by default and run in the active agent cwd, including the child cwd for inherited subagent hooks.

Hooks wrap built-in tools, including `read`, `bash`/`shell`, `hash_edit`, `write`, `grep`, `find`, `subagents`, and `web`. Message hooks also run after complete assistant messages or reasoning summaries. They run separately from the `bash` tool and do not recurse.

Each hook receives JSON on stdin. Its stdout/stderr stays local and cannot rewrite tool arguments or results. Only successful, explicitly enabled after hooks can inject provider context. Hook processes are bounded and cleaned up even after success; do not launch background jobs that must survive them.

## Quick start

Add this to `~/.magi-code/settings.json` under `automation.hooks`:

```json
{
  "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 audit scripts can use this body:

```sh
#!/usr/bin/env bash
set -euo pipefail
mkdir -p .magi-hooks
cat >> .magi-hooks/tool-hooks.jsonl
printf '\n' >> .magi-hooks/tool-hooks.jsonl
```

Mark them executable. Prefer reviewed script paths over inline shell; configured commands must pass the preflight below. Audit files can 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 a surface gate 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`. |

Hard runtime limit: hook stdin JSON stays capped at 1 MiB. Redacted payloads are summarized before spawn. Trusted `payload: "full"` bodies that would exceed the cap are moved to an ephemeral local `payload_ref` file when possible; otherwise stdin remains valid JSON with `payload.status: "omitted_too_large"` and bounded legacy summaries.

Hooks that exit or stop reading stdin early can cause a `BrokenPipe` while payload writer cleans up. This is normal hook behavior and is ignored for classification; hook exit status, timeout, cleanup warnings, and output limits still determine result. Only non-`BrokenPipe` stdin write or envelope failures are `stdin` failures.

Use `include_tools: ["bash", "write", "hash_edit"]` to select tools, or `exclude_tools: ["read", "grep"]` to skip them. Matching is exact; exclusions win. For a before-only policy gate, set `failure_policy: "block"` on that hook rather than globally.

### Lifecycle

For each provider-requested built-in tool call:

1. magi-code records the tool call in the session.
2. Matching `before_tool` hooks run in array order. Each matching hook writes sanitized local-only `hook_lifecycle` `started` and terminal (`success` or `failed`) records when sessions are enabled.
3. If all before hooks continue, target tool runs.
4. magi-code records the tool result in the session.
5. Matching `after_tool` hooks run in array order with both request and result payload fields. Each matching hook writes sanitized `hook_lifecycle` records 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.
6. Hook warnings are displayed locally and recorded as local-only `hook_diagnostic` session events.
7. Provider continuation receives the normal tool result, or a sanitized blocked-tool result if a before hook blocked the tool.

Session JSONL success 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 and never become provider-visible output. Inherited subagent hook records are written to the child session JSONL, not the parent session JSONL.

For message-phase hooks:

- `after_assistant` hooks fire after `SessionEventKind::AssistantOutput` is recorded. Payload `tool.name` is `"assistant"` and `request.text` holds the full assistant message text.
- `after_reasoning` hooks fire after `SessionEventKind::ReasoningSummary` is recorded. Payload `tool.name` is `"reasoning"` and `request.text` holds the full reasoning summary text.
- Both phases record `hook_lifecycle` and `hook_context_injection` audit events the same way as `after_tool`.

`hook_lifecycle` payloads use an allowlist: `phase`, `label`, `target_tool`, `status`, `policy`, `category`, `target_ran`, `tool_call_id`, `activity_id`, `hook_index`, `elapsed_ms`, and `message`. They never store hook command text, hook stdin JSON, stdout, stderr, tool arguments, tool results, environment values, credentials, auth codes, bearer headers, or account ids.

`hook_context_injection` audit payloads are also local-only and allowlisted: `schema_version`, `phase`, `label`, `target_tool`, `status`, `item_count`, `byte_count`, `max_bytes`, and `hook_index`. They never store raw stdout/stderr, hook stdin, tool args/results, or injected content text.

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_tui` shows live hook rows under the related tool or child task: phase, label, tool, running/success/failure, and relevant category/policy. Success does not create a `hook_diagnostic` event.
- `automation.hooks.injected_content.show_in_transcript` shows a separate injection row; `show_in_activity_tree` shows a related child row, or a root row without a parent. These controls are independent of `show_in_tui` and each other.
- `injected_content.style` defaults to `content` (redacted/truncated text). `metadata` shows 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 legacy fields remain: `phase`, `hook`, `tool`, `cwd`, `payload_mode`, `request`, and after hooks also `result`.

Before payload shape:

```json
{
  "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": "shell",
    "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 legacy 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`, `shell`, `mission_control`, `subagent`. Set by caller path, not inferred from prompt text. |
| `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. `read.path` and `ffgrep.path` use `kind: "read_target"`; `write.path` uses `kind: "write_target"`. `source` names the structured field, currently `request.path`. Unsafe traversal, cwd/root escapes, overlong paths, and symlink-resolved escapes 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.path` and sends only context plus bounded summaries on stdin.
- `payload_ref` includes `path`, `bytes`, `payload_mode`, `media_type: "application/json"`, and `digest_sha256`.
- Payload-ref files are private local artifacts (`0700` directories and `0600` files 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"`; legacy top-level `request`/`result` contain bounded summaries with `original_bytes`, `omitted_fields`, and `reason` rather than truncated JSON or raw large bytes.
- `payload_ref` paths, 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:

```json
{
  "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:

```json
{
  "context_items": [
    { "role": "user", "content": "Relevant bounded memory for next provider call." }
  ]
}
```

Rules:

- Only `after_tool`, `after_assistant`, and `after_reasoning` can inject. `before_tool` stdout never injects.
- 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 (for `after_tool`) or after the assistant/reasoning message (for `after_assistant` / `after_reasoning`).
- Multiple matching after hooks append in config order; multiple `context_items` append in JSON order.
- `provider_context_max_bytes` counts total UTF-8 content bytes per hook. Default `4096`, max `16384`. Set `stdout_max_bytes` at least as high as expected JSON stdout too; default stdout cap is `8192`, so 16KiB context needs a larger stdout cap or it fails as `hook_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_injection` records sanitized local audit status such as `success`, `invalid_json`, `invalid_shape`, `unsupported_role`, `empty_content`, `over_limit`, or `hook_failed`.
- Child subagent injections stay in child conversation/session. Parent provider requests and parent `subagents` results 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`, then `powershell.exe`) with `-NoProfile -NonInteractive -Command <command>`

Before spawning, magi-code rejects hook command strings containing shell expansion characters that make cwd-scoping ambiguous:

```text
$  ~  `  {  }
```

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](security.md#subprocess-environment-boundaries) for the exact environment profile.

### Script examples

This Python audit records only phase and tool name rather than whole payloads:

```python
#!/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`, and `hook_context_injection` audit 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_item` session 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_tool` block 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 `subagents` output 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. |