Skip to content

Commit 29f66bb

Browse files
committed
docs(hooks): expand hook docs and examples
1 parent 3ff2bd2 commit 29f66bb

3 files changed

Lines changed: 159 additions & 18 deletions

File tree

docs/config.md

Lines changed: 125 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -920,37 +920,150 @@ run = ["./scripts/bootstrap.sh"]
920920
timeout_ms = 60000
921921

922922
[[projects."/Users/me/src/my-app".hooks]]
923+
name = "lint-changed"
923924
event = "tool.after"
924925
run = "npm run lint -- --changed"
925926
```
926927

927-
Supported hook events:
928+
Hook configuration fields:
928929

929-
- `session.start`: after the session is configured (once per launch)
930-
- `session.end`: before shutdown completes
931-
- `tool.before`: immediately before each exec/tool command runs
932-
- `tool.after`: once an exec/tool command finishes (regardless of exit code)
933-
- `file.before_write`: right before an `apply_patch` is applied
934-
- `file.after_write`: after an `apply_patch` completes and diffs are emitted
935-
936-
Hook commands run inside the same sandbox mode as the session and appear in the TUI as their own exec cells. Failures are surfaced as background events but do not block the main task. Each invocation receives environment variables such as `CODE_HOOK_EVENT`, `CODE_HOOK_NAME`, `CODE_HOOK_INDEX`, `CODE_HOOK_CALL_ID`, `CODE_HOOK_PAYLOAD` (JSON describing the context), `CODE_SESSION_CWD`, and—when applicable—`CODE_HOOK_SOURCE_CALL_ID`. Hooks may also set `cwd`, provide additional `env` entries, and specify `timeout_ms`.
930+
| Field | Type | Notes |
931+
| --- | --- | --- |
932+
| `event` | string | Required. Hook event name (see list below). |
933+
| `run` | string \| array<string> | Required. Command to execute. String form is parsed like a shell command. |
934+
| `name` | string | Optional label used in logs and env vars. |
935+
| `cwd` | string | Optional working directory for the hook (relative paths resolve from project cwd). |
936+
| `env` | map<string,string> | Optional extra environment variables. |
937+
| `timeout_ms` | number | Optional timeout in milliseconds (no timeout when omitted). |
938+
| `run_in_background` | bool | Run without a TUI exec cell; still awaited and sandboxed. |
939+
940+
How hooks run:
941+
942+
- Hooks run in the order defined in the config. `CODE_HOOK_INDEX` reflects that order.
943+
- Hooks run inside the same sandbox and approval mode as the session; hooks cannot request escalation.
944+
- A hook may return control JSON to influence the workflow (details below). A hook may also stop further hooks for that event by returning `{"continue": false}` or exiting with status `2`.
945+
- For `tool.after` and `file.after_write`, stdout/stderr in the payload are truncated to 2048 characters.
946+
947+
Common environment variables (available in every hook):
948+
949+
- `CODE_HOOK_EVENT`: canonical event name (e.g., `tool.after`).
950+
- `CODE_HOOK_TRIGGER`: event slug (e.g., `tool_after`).
951+
- `CODE_HOOK_CALL_ID`: generated id for the hook exec.
952+
- `CODE_HOOK_SUB_ID`: current session/turn id.
953+
- `CODE_HOOK_INDEX`: 1-based index for this hook within the event.
954+
- `CODE_HOOK_PAYLOAD`: JSON payload string describing the context.
955+
- `CODE_SESSION_CWD`: project/session cwd.
956+
- `CODE_HOOK_NAME`: the configured hook name (if provided).
957+
- `CODE_HOOK_SOURCE_CALL_ID`: exec call id that triggered the hook (tool/file hooks only).
958+
959+
Common payload fields (inside `CODE_HOOK_PAYLOAD`):
960+
961+
- `event`: canonical event name.
962+
- `session_id`: session UUID.
963+
- `transcript_path`: path to the session transcript or `null` when unavailable.
964+
- `cwd`: project/session cwd.
965+
- `permission_mode`: `allow` or `ask` (based on the approval policy).
966+
- `hook_event_name`: legacy-style event name (e.g., `PreToolUse`).
967+
968+
### Hook events
969+
970+
Each payload includes the common fields above plus the event-specific data below.
971+
972+
| Event | When it fires | Payload additions |
973+
| --- | --- | --- |
974+
| `session.start` | After session config completes (once per launch). | `sandbox_policy`, `approval_policy`. |
975+
| `session.end` | Right before shutdown completes. | `sandbox_policy`, `approval_policy`. |
976+
| `user.prompt_submit` | When the user submits a prompt. | `user_prompt`. |
977+
| `tool.before` | Immediately before each exec/tool command. | `call_id`, `command`, `timeout_ms`, `cwd`. |
978+
| `tool.after` | After each exec/tool command (success or failure). | `call_id`, `command`, `timeout_ms`, `cwd`, `exit_code`, `duration_ms`, `timed_out`, `stdout`, `stderr`, `success`, plus `tool_result` (same fields nested). |
979+
| `file.before_write` | Right before an `apply_patch` is applied. | `call_id`, `command`, `timeout_ms`, `cwd`, `changes` (patch change list). |
980+
| `file.after_write` | After an `apply_patch` completes. | Same as `file.before_write`, plus `exit_code`, `duration_ms`, `timed_out`, `stdout`, `stderr`, `success`. |
981+
| `pre.compact` | Immediately before auto-compact runs. | `reason`. |
982+
| `post.compact` | Right after auto-compact finishes. | `reason`. |
983+
| `stop` | When a turn completes normally. | `reason` (e.g., `turn_complete`), `details` with `last_assistant_message`. |
984+
| `subagent.stop` | When a sub-agent finishes. | `reason` (e.g., `subagent_complete`), `details` with `agent_id`, `agent_name`, `status`. |
985+
| `notification` | Whenever a user notification is emitted. | `notification` (full `UserNotification` payload). |
937986

938987
Example `tool.after` payload:
939988

940989
```json
941990
{
942991
"event": "tool.after",
992+
"session_id": "3e8b...",
993+
"hook_event_name": "PostToolUse",
994+
"permission_mode": "allow",
943995
"call_id": "tool_12",
944996
"cwd": "/Users/me/src/my-app",
945997
"command": ["npm", "test"],
998+
"timeout_ms": 120000,
946999
"exit_code": 1,
9471000
"duration_ms": 1832,
948-
"stdout": "…output truncated…",
949-
"stderr": "",
950-
"timed_out": false
1001+
"timed_out": false,
1002+
"stdout": "...truncated...",
1003+
"stderr": "...truncated...",
1004+
"success": false,
1005+
"tool_result": {
1006+
"exit_code": 1,
1007+
"duration_ms": 1832,
1008+
"timed_out": false,
1009+
"stdout": "...truncated...",
1010+
"stderr": "...truncated...",
1011+
"success": false
1012+
}
1013+
}
1014+
```
1015+
1016+
### Hook output (control JSON)
1017+
1018+
Hooks can print JSON to stdout or stderr to influence the flow. The parser accepts raw JSON or JSON wrapped in a fenced code block.
1019+
1020+
Supported keys:
1021+
1022+
- `continue`: `true`/`false` — stop running remaining hooks for this event when `false`.
1023+
- `systemMessage`: string — appended as a system message (Chat API only).
1024+
- `permissionDecision`: `allow` \| `ask` \| `deny` — gate `user.prompt_submit` or `tool.before`/`file.before_write`.
1025+
- `hookSpecificOutput.permissionDecision`: same as above (preferred nesting for structured output).
1026+
- `updatedInput`: modifies the incoming payload.
1027+
- For `user.prompt_submit`, it can be a string, `InputItem`, or list of `InputItem`s.
1028+
- For `tool.before`/`file.before_write`, it can be a string/array command or an object with `command`, `cwd`, `timeout_ms`, and `env`.
1029+
1030+
Example: prompt gate (used by `hooks/ask_user_prompt.py`)
1031+
1032+
```json
1033+
{
1034+
"hookSpecificOutput": {"permissionDecision": "ask"},
1035+
"systemMessage": "Hook gate: approve this prompt to continue."
9511036
}
9521037
```
9531038

1039+
Example: rewrite a tool command before execution
1040+
1041+
```json
1042+
{
1043+
"updatedInput": {
1044+
"command": ["npm", "run", "lint", "--", "--changed"],
1045+
"timeout_ms": 60000
1046+
}
1047+
}
1048+
```
1049+
1050+
### Testing hooks locally
1051+
1052+
This repo ships two example hooks under `hooks/` and a helper script:
1053+
1054+
- `hooks/ask_user_prompt.py`: prompts for approval when the user prompt starts with `hook:`.
1055+
- `hooks/check_shell.py`: requests confirmation for risky shell commands (`rm -rf`, `mkfs`, etc.).
1056+
- `scripts/run-hooks-test.sh`: launches Code with a temporary config that enables those hooks.
1057+
1058+
Test workflow:
1059+
1060+
```bash
1061+
./build-fast.sh
1062+
scripts/run-hooks-test.sh
1063+
```
1064+
1065+
Then try a prompt like `hook: please summarize this repo` or run a shell command containing `rm -rf` to see the hook gate in action.
1066+
9541067
## Project Commands
9551068

9561069
Define project-scoped commands under `[[projects."<path>".commands]]`. Each command needs a unique `name` and either an array (`command`) or string (`run`) describing how to invoke it. Optional fields include `description`, `cwd`, `env`, and `timeout_ms`.

hooks/ask_user_prompt.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,27 @@
11
#!/usr/bin/env python3
22
import json
3+
import os
34
import sys
45

5-
def main():
6+
7+
def load_payload():
68
try:
79
payload = json.load(sys.stdin)
10+
if payload:
11+
return payload
812
except Exception:
9-
payload = {}
13+
pass
14+
15+
env_payload = os.environ.get("CODE_HOOK_PAYLOAD")
16+
if env_payload:
17+
try:
18+
return json.loads(env_payload)
19+
except Exception:
20+
return {}
21+
return {}
22+
23+
def main():
24+
payload = load_payload()
1025

1126
prompt = (payload.get("user_prompt") or "").strip()
1227
lowered = prompt.lower()

hooks/check_shell.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,29 @@
11
#!/usr/bin/env python3
22
import json
3+
import os
34
import sys
45

56
def load_payload():
67
try:
7-
return json.load(sys.stdin)
8+
payload = json.load(sys.stdin)
9+
if payload:
10+
return payload
811
except Exception:
9-
return {}
12+
payload = None
13+
14+
env_payload = os.environ.get("CODE_HOOK_PAYLOAD")
15+
if env_payload:
16+
try:
17+
return json.loads(env_payload)
18+
except Exception:
19+
return {}
20+
return payload or {}
1021

1122
def command_from_payload(payload):
12-
tool_input = payload.get("tool_input") or {}
13-
cmd = tool_input.get("command")
23+
cmd = payload.get("command")
24+
if cmd is None:
25+
tool_input = payload.get("tool_input") or {}
26+
cmd = tool_input.get("command")
1427
if isinstance(cmd, list):
1528
return " ".join(cmd)
1629
if isinstance(cmd, str):

0 commit comments

Comments
 (0)