* feat(hooks): add MessageDisplay hook for mid-turn streaming Fires repeatedly as the assistant reply streams, before Stop (which only fires once at the end of the turn). Fire-and-forget, cumulative text payload, debounced (~200ms) except for the unconditional final firing. Fires from the single streaming loop in client.ts shared by the terminal UI and ACP paths. Fixes #6488 * fix(hooks): address MessageDisplay review feedback - Chain fire-and-forget MessageDisplay requests per message_id instead of firing them fully unbounded, so a slow hook command can't pile up concurrent processes. - Gate the final flush on non-empty displayed_text and !signal.aborted, matching the adjacent Stop hook's guard. - Document why the final flush intentionally re-sends the last debounced text (is_final itself is new information). - Simplify the debounce constant's JSDoc to drop the competitor comparison. - Add tests for the mid-stream debounced flush and the rejected-request warn path. * test(hooks): drain microtasks before asserting on chained MessageDisplay calls fireMessageDisplayHook now chains per-message_id through a promise (see previous commit), so the final flush's actual messageBus.request() call lands a few microtask ticks after the generator itself finishes — the mid-stream-flush test needs to let that chain settle before asserting. * fix(hooks): flush MessageDisplay is_final on every for-await exit path The three early `return turn` paths inside the streaming loop (always-on loop-detection safety, heuristic loop detection, and the stream Error event) exited before the final MessageDisplay flush, which only sat after the loop ended normally. Hook scripts relying on is_final: true to know when to flush never received it when a turn ended via loop detection or an API error. Extracts the flush into a shared closure and calls it from all four exits (the three early returns plus the normal fall-through), instead of only the one at the bottom of the loop. Adds regression tests for all three previously missed exits, plus the two guard-coverage tests requested in review (abort suppresses the flush, a tool-call-only turn with no Content events does not fire a vacuous empty-text event). Addresses the outstanding critical review comment and the follow-up test coverage suggestion on PR #6489. * fix(hooks): fire MessageDisplay on the ACP surface, coalesce delivery, drain is_final before turn end Addresses the three findings from the local verification report on #6489: - ACP/qwen serve (Finding 1): the delivery logic now lives in a shared MessageDisplayDispatcher (packages/core), and Session.ts wires it into all four raw-stream loops (main prompt, Stop-hook continuation, cron tick, background notification) — these surfaces consume GeminiChat's stream directly and never enter GeminiClient.sendMessageStream, so they need their own fire sites. The daemon no longer advertises an event it never emits. - Slow-hook backlog (Finding 2): the per-message promise chain is replaced by coalescing delivery — at most one in-flight request plus one pending payload per message; newer flushes overwrite the pending slot, which is lossless because displayed_text is cumulative, and is_final is sticky. A slow hook now sees fewer, newer payloads instead of an ever-growing queue of stale ones. - Headless is_final drop (Finding 3): finish() resolves only once every enqueued payload has actually been delivered, and every exit out of the streaming loops awaits it (early returns, normal fall-through, and the enclosing finally for uncaught exceptions), so a short-lived -p process can no longer exit with the final payload still queued. As a consequence, is_final delivery now strictly precedes the Stop hook rather than racing it. Also: the failure log line carries the message_id, finish() is idempotent, the review-requested tests are added (mid-stream and final firings share one message_id; isFinal as the sole flush reason), and hooks.md gains a delivery-semantics contract covering coalescing, the drain guarantee, no is_final on cancellation, provisional displayed_text, and multiple messages per tool-using turn. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(hooks): bound MessageDisplay drain wait, fix test gaps flagged in review finish() now gives up waiting on drain after 5s (MESSAGE_DISPLAY_DRAIN_TIMEOUT_MS) instead of blocking turn teardown for up to the full 60s hook timeout, per the re-verification's S1 finding. Delivery keeps running in the background past the timeout; only the caller's wait is bounded. Also: add the config.ts bridge test for MessageDisplay field extraction (S5), and add the missing MessageDisplay/InstructionsLoaded entries to acpAgent.test.ts's HookEventName mock (S6). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(hooks): dispatch MessageDisplay is_final alongside stale deliveries, share one drain budget Round-3 review findings on #6489: - finish() no longer queues the is_final payload behind an in-flight mid-stream delivery: the pending slot's supersession argument applies to the in-flight slot too, so the final payload is dispatched immediately, alongside the stale delivery if one is still running. is_final is handed to the hook the moment the message ends — before Stop — on every surface, and can no longer be dropped by a short-lived process exiting with it still queued (Finding 1). - The bounded drain wait is memoized: every finish() call (explicit, finally, or concurrent) shares one promise and one timer, so the teardown ceiling is MESSAGE_DISPLAY_DRAIN_TIMEOUT_MS itself, not a multiple of it (Finding 2). - hooks.md delivery semantics rewritten to match the shipped behavior, including the headless orphaned-hook caveat and the unspecified completion order between an overlapped stale execution and the final one (Finding 3). - The dispatcher mirrors its warnings to console.warn itself (stderr on headless/ACP, ink patchConsole in the TUI) in addition to the injected debug-file sink, so hitting the drain timeout is visible by default (Finding 4). - A superseded mid-stream delivery that fails after the final was dispatched no longer warns; failures during streaming still do. - New tests: finish() twice while delivery is in flight (the exact client.ts sequence), concurrent finish() calls sharing one budget, is_final overtaking a held mid-stream delivery, and drain resolving on the final delivery alone. * refactor(core): consolidate MessageDisplay finish() calls, dedupe test spy setup client.ts: wrap the turn.run() streaming loop in try/finally so messageDisplay.finish() fires once instead of at each of the three early-return sites plus the post-loop path -- matching the pattern the four raw-stream loops in Session.ts already use for the same dispatcher. message-display-dispatcher.test.ts: centralize the console.warn spy setup/teardown in beforeEach/afterEach instead of five repeated per-test try/finally blocks. No behavior change: full client.test.ts (246/246) and the message-display-buffer/dispatcher suites (24/24) pass unchanged. * docs(hooks): clarify MessageDisplay cancellation timing (round-4 nit) * test(hooks): cover the 3 untested MessageDisplay dispatch sites, fix cancellation doc wording Adds MessageDisplay is_final coverage for the Stop-hook continuation loop, the in-session cron fire, and the background-notification loop, each with a normal-completion and an abort case. Adds three MessageDisplayDispatcher edge-case tests: a delivery settling just before the drain timeout, an abort arriving after a drain wait has already started, and addChunk called after abort but before finish(). Rewords the cancellation-timing doc bullet to state the actual criterion (abort signal state when finish() runs) rather than an approximation of it. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
46 KiB
Qwen Code Hooks
Overview
Qwen Code hooks provide a powerful mechanism for extending and customizing the behavior of the Qwen Code application. Hooks allow users to execute custom scripts or programs at specific points in the application lifecycle, such as before tool execution, after tool execution, at session start/end, and during other key events.
Hooks are enabled by default. You can temporarily disable all hooks by setting disableAllHooks to true in your settings file (at the top level, alongside hooks):
{
"disableAllHooks": true,
"hooks": {
"PreToolUse": [...]
}
}
This disables all hooks without deleting their configurations.
What are Hooks?
Hooks are user-defined scripts or programs that are automatically executed by Qwen Code at predefined points in the application flow. They allow users to:
- Monitor and audit tool usage
- Enforce security policies
- Inject additional context into conversations
- Customize application behavior based on events
- Integrate with external systems and services
- Modify tool inputs or responses programmatically
Hook Types
Qwen Code supports four hook executor types:
| Type | Description |
|---|---|
command |
Execute a shell command. Receives JSON via stdin, returns results via stdout. |
http |
Send JSON as a POST request body to a specified URL. Returns results via HTTP response body. |
function |
Directly call a registered JavaScript function (session-level hooks only). |
prompt |
Use an LLM to evaluate hook input and return a decision. |
Command Hooks
Command hooks execute commands via child processes. Input JSON is passed through stdin, and output is returned via stdout.
Configuration:
| Field | Type | Required | Description |
|---|---|---|---|
type |
"command" |
Yes | Hook type |
command |
string |
Yes | Command to execute |
name |
string |
No | Hook name (for logging) |
description |
string |
No | Hook description |
timeout |
number |
No | Timeout in milliseconds, default 60000 |
async |
boolean |
No | Whether to run asynchronously in background |
env |
Record<string, string> |
No | Environment variables |
shell |
"bash" | "powershell" |
No | Shell to use |
statusMessage |
string |
No | Status message displayed during execution |
Example:
{
"hooks": {
"PreToolUse": [
{
"matcher": "write_file",
"hooks": [
{
"type": "command",
"command": "$QWEN_PROJECT_DIR/.qwen/hooks/security-check.sh",
"name": "security-check",
"timeout": 10000
}
]
}
]
}
}
HTTP Hooks
HTTP hooks send hook input as POST requests to specified URLs. They support URL whitelists, DNS-level SSRF protection, environment variable interpolation, and other security features.
Configuration:
| Field | Type | Required | Description |
|---|---|---|---|
type |
"http" |
Yes | Hook type |
url |
string |
Yes | Target URL |
headers |
Record<string, string> |
No | Request headers (supports env var interpolation) |
allowedEnvVars |
string[] |
No | Whitelist of environment variables allowed in URL/headers |
timeout |
number |
No | Timeout in seconds, default 600 |
name |
string |
No | Hook name (for logging) |
statusMessage |
string |
No | Status message displayed during execution |
once |
boolean |
No | Execute only once per event per session (HTTP hooks only) |
Security Features:
- URL Whitelist: Configure allowed URL patterns via
allowedUrls - SSRF Protection: Blocks private IPs (10.x.x.x, 172.16-31.x.x, 192.168.x.x, etc.) but allows loopback addresses (127.0.0.1, ::1)
- DNS Validation: Validates domain resolution before requests to prevent DNS rebinding attacks
- Environment Variable Interpolation:
${VAR}syntax, only allows variables inallowedEnvVarswhitelist
Example:
{
"hooks": {
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "http",
"url": "http://127.0.0.1:8080/hooks/pre-tool-use",
"headers": {
"Authorization": "Bearer ${HOOK_API_KEY}"
},
"allowedEnvVars": ["HOOK_API_KEY"],
"timeout": 10,
"name": "remote-security-check"
}
]
}
]
}
}
Function Hooks
Function hooks directly call registered JavaScript/TypeScript functions. They are used internally by the Skill system and are not currently exposed as a public API for end users.
Note: For most use cases, use command hooks or HTTP hooks instead, which can be configured in settings files.
Prompt Hooks
Prompt hooks use an LLM to evaluate hook input and return a decision. This is useful for making intelligent decisions based on context, such as determining whether to allow or block an operation.
How it works:
- The hook input JSON is injected into your prompt using the
$ARGUMENTSplaceholder - The prompt is sent to an LLM (default: your current model)
- The LLM returns a JSON response with the decision
- Qwen Code processes the decision and continues or blocks execution accordingly
Configuration:
| Field | Type | Required | Description |
|---|---|---|---|
type |
"prompt" |
Yes | Hook type |
prompt |
string |
Yes | Prompt sent to LLM. Use $ARGUMENTS for hook input |
model |
string |
No | Model to use (defaults to your current model) |
timeout |
number |
No | Timeout in seconds, default 30 |
name |
string |
No | Hook name (for logging) |
description |
string |
No | Hook description |
statusMessage |
string |
No | Status message displayed during execution |
Response Format:
The LLM must return JSON with the following structure:
{
"ok": true,
"reason": "Explanation of the decision",
"additionalContext": "Optional context to inject into the conversation"
}
| Field | Description |
|---|---|
ok |
true to allow/continue, false to block/stop |
reason |
Required when ok is false. Shown to the model to explain the block |
additionalContext |
Optional. Additional context to inject into the conversation when allowing |
Supported Events:
Prompt hooks can be used with most hook events, including:
PreToolUse- Evaluate whether to allow a tool callPostToolUse- Evaluate tool results and potentially inject contextStop- Determine whether to continue or stopSubagentStop- Evaluate subagent resultsUserPromptSubmit- Evaluate or enrich user prompts
Example: Stop Hook
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "You are evaluating whether Qwen Code should stop working. Context: $ARGUMENTS\n\nAnalyze the conversation and determine if:\n1. All user-requested tasks are complete\n2. Any errors need to be addressed\n3. Follow-up work is needed\n\nRespond with JSON: {\"ok\": true} to allow stopping, or {\"ok\": false, \"reason\": \"your explanation\"} to continue working.",
"timeout": 30
}
]
}
]
}
}
When ok is false, Qwen Code will continue working and use the reason as context for the next response.
Example: PreToolUse Hook
{
"hooks": {
"PreToolUse": [
{
"matcher": "run_shell_command",
"hooks": [
{
"type": "prompt",
"prompt": "Evaluate this tool call for security concerns. Tool input: $ARGUMENTS\n\nCheck for:\n- Dangerous commands (rm -rf, curl | sh, etc.)\n- Unauthorized access attempts\n- Data exfiltration patterns\n\nRespond with {\"ok\": true} if safe, or {\"ok\": false, \"reason\": \"concern\"} if blocked.",
"model": "sonnet",
"timeout": 30,
"name": "security-evaluator"
}
]
}
]
}
}
Hook Events
Hooks fire at specific points during a Qwen Code session. Different events support different matchers to filter trigger conditions.
| Event | Triggered When | Matcher Target |
|---|---|---|
PreToolUse |
Before tool execution | Tool id (write_file, read_file, run_shell_command, etc.) |
PostToolUse |
After successful tool execution | Tool id |
PostToolUseFailure |
After tool execution fails | Tool id |
UserPromptSubmit |
After user submits prompt | None (always fires) |
SessionStart |
When session starts or resumes | Source (startup, resume, clear, compact) |
SessionEnd |
When session ends | Reason (clear, logout, prompt_input_exit, etc.) |
MessageDisplay |
Repeatedly, as the reply streams | None (always fires) |
Stop |
When Claude prepares to conclude response | None (always fires) |
SubagentStart |
When subagent starts | Agent type (Bash, Explorer, Plan, etc.) |
SubagentStop |
When subagent stops | Agent type |
PreCompact |
Before conversation compaction | Trigger (manual, auto) |
Notification |
When notifications are sent | Type (permission_prompt, idle_prompt, auth_success) |
PermissionRequest |
When permission dialog is shown | Tool id |
PermissionDenied |
When tool permission is denied | Tool id |
TodoCreated |
When a new todo item is created | None (always fires) |
TodoCompleted |
When a todo item is marked as completed | None (always fires) |
Matcher Patterns
matcher is a regular expression used to filter trigger conditions.
| Event Type | Events | Matcher Support | Matcher Target |
|---|---|---|---|
| Tool Events | PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest, PermissionDenied |
✅ Regex | Tool id: write_file, read_file, run_shell_command, etc. |
| Subagent Events | SubagentStart, SubagentStop |
✅ Regex | Agent type: Bash, Explorer, etc. |
| Session Events | SessionStart |
✅ Regex | Source: startup, resume, clear, compact |
| Session Events | SessionEnd |
✅ Regex | Reason: clear, logout, prompt_input_exit, etc. |
| Notification Events | Notification |
✅ Exact match | Type: permission_prompt, idle_prompt, auth_success |
| Compact Events | PreCompact |
✅ Exact match | Trigger: manual, auto |
| Todo Events | TodoCreated, TodoCompleted |
❌ No | N/A |
| Prompt Events | UserPromptSubmit |
❌ No | N/A |
| Stop Events | Stop |
❌ No | N/A |
| Message Display | MessageDisplay |
❌ No | N/A |
Matcher Syntax:
- Empty string
""or"*"matches all events of that type - Standard regex syntax supported (e.g.,
^run_shell_command$,read_.*,(write_file|edit)) - Tool hooks receive the runtime tool id in
tool_name(for example,write_file). Built-in display names such asWriteFileandReadFileare also accepted as matcher aliases for compatibility, but new configs should prefer runtime ids.
Examples:
{
"hooks": {
"PreToolUse": [
{
"matcher": "^run_shell_command$",
"hooks": [
{
"type": "command",
"command": "echo 'bash check' >> /tmp/hooks.log"
}
]
},
{
"matcher": "write_.*",
"hooks": [
{
"type": "command",
"command": "echo 'write check' >> /tmp/hooks.log"
}
]
},
{
"matcher": "*",
"hooks": [
{ "type": "command", "command": "echo 'all tools' >> /tmp/hooks.log" }
]
}
],
"SubagentStart": [
{
"matcher": "^(Bash|Explorer)$",
"hooks": [
{
"type": "command",
"command": "echo 'subagent check' >> /tmp/hooks.log"
}
]
}
]
}
}
Input/Output Rules
Hook Input Structure
All hooks receive standardized input in JSON format through stdin (command) or POST body (http).
Common Fields:
{
"session_id": "string",
"transcript_path": "string",
"cwd": "string",
"hook_event_name": "string",
"timestamp": "string"
}
Event-specific fields are added based on the hook type. When running in a subagent, agent_id and agent_type are additionally included.
Hook Output Structure
Hook output is returned via stdout (command) or HTTP response body (http) as JSON.
Exit Code Behavior (Command Hooks):
| Exit Code | Behavior |
|---|---|
0 |
Success. Parse JSON in stdout to control behavior. |
2 |
Blocking error. Ignores stdout, passes stderr as error feedback to the model. |
| Other | Non-blocking error. stderr only shown in debug mode, execution continues. |
Output Structure:
Hook output supports three categories of fields:
- Common Fields:
continue,stopReason,suppressOutput,systemMessage - Top-level Decision:
decision,reason(used by some events) - Event-specific Control:
hookSpecificOutput(must includehookEventName)
{
"continue": true,
"decision": "allow",
"reason": "Operation approved",
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext": "Additional context information"
}
}
Individual Hook Event Details
PreToolUse
Purpose: Executed before a tool is used to allow for permission checks, input validation, or context injection.
Event-specific fields:
{
"permission_mode": "default | plan | auto_edit | yolo",
"tool_name": "name of the tool being executed",
"tool_input": "object containing the tool's input parameters",
"tool_use_id": "unique identifier for this tool use instance (internal format, e.g., toolu_xxx)",
"tool_call_id": "original API call ID from the LLM provider (e.g., call_xxx for OpenAI/Qwen) (optional)"
}
Output Options:
hookSpecificOutput.permissionDecision: "allow", "deny", or "ask" (REQUIRED)hookSpecificOutput.permissionDecisionReason: explanation for the decision (REQUIRED)hookSpecificOutput.updatedInput: modified tool input parameters to use instead of originalhookSpecificOutput.additionalContext: additional context information
The permissionDecision value controls whether the tool runs:
"allow"— run the tool without the usual approval prompt."deny"— block the tool; it does not execute and an error is returned to the model."ask"— pause and ask the user to confirm the tool call in the TUI before it runs. Confirming runs the tool once; declining cancels it. In contexts that cannot prompt for confirmation — headless (--prompt) runs and background subagents —"ask"falls back to"deny".
Note: While standard hook output fields like decision and reason are technically supported by the underlying class, the official interface expects the hookSpecificOutput with permissionDecision and permissionDecisionReason.
Example Output:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Security policy blocks database writes",
"additionalContext": "Current environment: production. Proceed with caution."
}
}
PostToolUse
Purpose: Executed after a tool completes successfully to process results, log outcomes, or inject additional context.
Event-specific fields:
{
"permission_mode": "default | plan | auto_edit | yolo",
"tool_name": "name of the tool that was executed",
"tool_input": "object containing the tool's input parameters",
"tool_response": "object containing the tool's response",
"tool_use_id": "unique identifier for this tool use instance (internal format, e.g., toolu_xxx)",
"tool_call_id": "original API call ID from the LLM provider (e.g., call_xxx for OpenAI/Qwen) (optional)"
}
Output Options:
decision: "allow", "deny", "block" (defaults to "allow" if not specified)reason: reason for the decisionhookSpecificOutput.additionalContext: additional information to be included
Example Output:
{
"decision": "allow",
"reason": "Tool executed successfully",
"hookSpecificOutput": {
"additionalContext": "File modification recorded in audit log"
}
}
PostToolUseFailure
Purpose: Executed when a tool execution fails to handle errors, send alerts, or record failures.
Event-specific fields:
{
"permission_mode": "default | plan | auto_edit | yolo",
"tool_use_id": "unique identifier for the tool use (internal format, e.g., toolu_xxx)",
"tool_call_id": "original API call ID from the LLM provider (e.g., call_xxx for OpenAI/Qwen) (optional)",
"tool_name": "name of the tool that failed",
"tool_input": "object containing the tool's input parameters",
"error": "error message describing the failure",
"is_interrupt": "boolean indicating if failure was due to user interruption (optional)"
}
Output Options:
hookSpecificOutput.additionalContext: error handling information- Standard hook output fields
Example Output:
{
"hookSpecificOutput": {
"additionalContext": "Error: File not found. Failure logged in monitoring system."
}
}
UserPromptSubmit
Purpose: Executed when the user submits a prompt to modify, validate, or enrich the input.
Event-specific fields:
{
"prompt": "the user's submitted prompt text"
}
Output Options:
decision: "allow", "deny", "block", or "ask"reason: human-readable explanation for the decisionhookSpecificOutput.additionalContext: additional context to append to the prompt (optional)
Note: Since UserPromptSubmitOutput extends HookOutput, all standard fields are available but only additionalContext in hookSpecificOutput is specifically defined for this event.
Example Output:
{
"decision": "allow",
"reason": "Prompt reviewed and approved",
"hookSpecificOutput": {
"additionalContext": "Remember to follow company coding standards."
}
}
SessionStart
Purpose: Executed when a new session starts to perform initialization tasks.
Event-specific fields:
{
"permission_mode": "default | plan | auto_edit | yolo",
"source": "startup | resume | clear | compact",
"model": "the model being used",
"agent_type": "the type of agent if applicable (optional)"
}
Output Options:
hookSpecificOutput.additionalContext: context to be available in the session- Standard hook output fields
Example Output:
{
"hookSpecificOutput": {
"additionalContext": "Session started with security policies enabled."
}
}
SessionEnd
Purpose: Executed when a session ends to perform cleanup tasks.
Event-specific fields:
{
"reason": "clear | logout | prompt_input_exit | bypass_permissions_disabled | other"
}
Output Options:
- Standard hook output fields (typically not used for blocking)
MessageDisplay
Purpose: Fires repeatedly as the assistant's reply streams — before Stop, which fires once at the end of the turn. Useful for live narration, incremental logging, or any consumer that wants to react to the reply as it's written rather than after the fact. This is a fire-and-forget event - hook output and exit codes are ignored.
Event-specific fields:
{
"message_id": "stable id for the whole streamed message",
"displayed_text": "the CUMULATIVE text streamed so far for this message (not a delta)",
"is_final": "true on the last firing for this message, false otherwise"
}
displayed_text is cumulative rather than a delta so hook scripts never need to reassemble chunks themselves — each firing carries the full text so far. Firing is debounced (at most every ~200ms) except for the final firing (is_final: true), which always fires once the message ends, so the reply's tail is never dropped waiting on the debounce window.
Delivery semantics — what a hook script can rely on:
- Slow hooks see fewer, newer payloads. At most one mid-stream hook execution per message is in flight at a time; while one runs, newer debounced payloads replace the queued one rather than piling up behind it. A hook slower than the debounce window therefore skips intermediate snapshots — lossless, since each payload carries the full cumulative text.
is_finalis never queued behind a stale delivery. The final payload is dispatched the moment the message ends — alongside a still-running mid-stream execution if there is one (the one exception to the one-at-a-time rule, justified the same way: the final cumulative text strictly supersedes whatever that execution is processing). Your hook always receives theis_finalpayload, and receives it before theStophook fires. One consequence for stateful hooks: when the final execution overlaps a superseded mid-stream one, their completion order is unspecified — the stale execution may finish after the final one (even afterStop). Treatis_finalas terminal permessage_idand let the cumulative text win, rather than assuming the last execution to finish carries the newest state.- The turn waits for
is_finaldelivery to complete — but not forever. The turn's end (and theStophook, when it fires) waits up to 5 seconds for the final delivery to finish. A hook that completes within that budget keeps the strongest guarantee: a headless run (qwen -p ...) exits only after the hook finished, and theis_finalexecution completes beforeStopstarts. A slower hook still receivesis_finalfirst — only the wait for its completion is bounded: in the terminal UI or an ACP session the execution simply finishes in the background, while a headless run exits without waiting. The hook process is not killed on exit; it is left to finish on its own, so a script chainingqwen -p … && next-stepcan observenext-stepstarting while a slow hook is still running. Hitting this timeout prints a warning on stderr. - Cancellation behaviour depends on timing. A turn cancelled before
is_finaldispatches fires nois_final— the message is treated as abandoned, and a consumer that buffers untilis_finalshould treat cancellation-silence as its flush/discard signal (e.g. a timeout fallback). The criterion is the abort signal's state at the moment the turn ends, not whether every chunk had already streamed — an abort landing in the brief gap before that check can still suppressis_finalfor a message whose text had, in practice, finished arriving. Cancelling afteris_finalhas dispatched (during the drain wait) is different: the still-running hook execution may be terminated mid-flight (SIGTERM), but the payload itself has already been delivered. displayed_textis provisional untilis_final. It reflects what has streamed so far; treat intermediate payloads as display state, not as authoritative final content.- A tool-using turn produces multiple messages. Each model call gets its own
message_idwith its ownis_final: truefiring: the text before a tool call is one message, the continuation after the tool result is another. Model calls that produce no displayed text (tool-call-only) fire nothing.
Note: Fires in the terminal UI, headless (-p), and ACP (IDE/editor/qwen serve) sessions, with the same payload contract on every surface.
Stop
Purpose: Executed before Qwen concludes its response to provide final feedback or summaries.
Event-specific fields:
{
"stop_hook_active": "boolean indicating if stop hook is active",
"last_assistant_message": "the last message from the assistant",
"context_usage": "ratio of context window used (may exceed 1 when tokens exceed window; optional)",
"context_limit": "context window size in tokens (optional)",
"input_tokens": "prompt token count (may include output tokens depending on provider; optional)"
}
The context_usage, context_limit, and input_tokens fields allow hook scripts to observe context usage and implement custom compact strategies — for example, a script that prints a reminder to run /compact when usage exceeds a custom threshold.
Output Options:
decision: "allow", "deny", "block", or "ask"reason: human-readable explanation for the decisionstopReason: feedback to include in the stop responsecontinue: set to false to stop executionhookSpecificOutput.additionalContext: additional context information
Note: Since StopOutput extends HookOutput, all standard fields are available but the stopReason field is particularly relevant for this event.
Example Output:
{
"decision": "block",
"reason": "Must be provided when Qwen Code is blocked from stopping"
}
StopFailure
Purpose: Executed when the turn ends due to an API error (instead of Stop). This is a fire-and-forget event - hook output and exit codes are ignored.
Event-specific fields:
{
"error": "rate_limit | authentication_failed | billing_error | invalid_request | server_error | max_output_tokens | unknown",
"error_details": "detailed error message (optional)",
"last_assistant_message": "the last message from the assistant before the error (optional)"
}
Matcher: Matches against the error field. For example, "matcher": "rate_limit" will only trigger for rate limit errors.
Output Options:
- None - StopFailure is fire-and-forget. All hook output and exit codes are ignored.
Exit Code Handling:
| Exit Code | Behavior |
|---|---|
| Any | Ignored (fire-and-forget) |
Example Configuration:
{
"hooks": {
"StopFailure": [
{
"matcher": "rate_limit",
"hooks": [
{
"type": "command",
"command": "/path/to/rate-limit-alert.sh",
"name": "rate-limit-alerter"
}
]
}
]
}
}
Use Cases:
- Rate limit monitoring and alerting
- Authentication failure logging
- Billing error notifications
- Error statistics collection
SubagentStart
Purpose: Executed when a subagent (like the Task tool) is started to set up context or permissions.
Event-specific fields:
{
"permission_mode": "default | plan | auto_edit | yolo",
"agent_id": "identifier for the subagent",
"agent_type": "type of agent (Bash, Explorer, Plan, Custom, etc.)"
}
Output Options:
hookSpecificOutput.additionalContext: initial context for the subagent- Standard hook output fields
Example Output:
{
"hookSpecificOutput": {
"additionalContext": "Subagent initialized with restricted permissions."
}
}
SubagentStop
Purpose: Executed when a subagent finishes to perform finalization tasks.
Event-specific fields:
{
"permission_mode": "default | plan | auto_edit | yolo",
"stop_hook_active": "boolean indicating if stop hook is active",
"agent_id": "identifier for the subagent",
"agent_type": "type of agent",
"agent_transcript_path": "path to the subagent's transcript",
"last_assistant_message": "the last message from the subagent"
}
Output Options:
decision: "allow", "deny", "block", or "ask"reason: human-readable explanation for the decision
Example Output:
{
"decision": "block",
"reason": "Must be provided when Qwen Code is blocked from stopping"
}
PreCompact
Purpose: Executed before conversation compaction to prepare or log the compaction.
Event-specific fields:
{
"trigger": "manual | auto",
"custom_instructions": "custom instructions currently set"
}
Output Options:
hookSpecificOutput.additionalContext: context to include before compaction- Standard hook output fields
Example Output:
{
"hookSpecificOutput": {
"additionalContext": "Compacting conversation to maintain optimal context window."
}
}
PostCompact
Purpose: Executed after conversation compaction completes to archive summaries or track usage.
Event-specific fields:
{
"trigger": "manual | auto",
"compact_summary": "the summary generated by the compaction process"
}
Matcher: Matches against the trigger field. For example, "matcher": "manual" will only trigger for manual compaction via /compact command.
Output Options:
hookSpecificOutput.additionalContext: additional context (for logging only)- Standard hook output fields (for logging only)
Note: PostCompact is not in the official decision mode supported events list. The decision field and other control fields do not produce any control effects - they are only used for logging purposes.
Exit Code Handling:
| Exit Code | Behavior |
|---|---|
| 0 | Success - stdout shown to user in verbose mode |
| Other | Non-blocking error - stderr shown to user in verbose mode |
Example Configuration:
{
"hooks": {
"PostCompact": [
{
"matcher": "manual",
"hooks": [
{
"type": "command",
"command": "/path/to/save-compact-summary.sh",
"name": "save-summary"
}
]
}
]
}
}
Use Cases:
- Summary archiving to files or databases
- Usage statistics tracking
- Context change monitoring
- Audit logging for compaction operations
Notification
Purpose: Executed when notifications are sent to customize or intercept them.
Event-specific fields:
{
"message": "notification message content",
"title": "notification title (optional)",
"notification_type": "permission_prompt | idle_prompt | auth_success"
}
Note
:
elicitation_dialogtype is defined but not currently implemented.
Output Options:
hookSpecificOutput.additionalContext: additional information to include- Standard hook output fields
Example Output:
{
"hookSpecificOutput": {
"additionalContext": "Notification processed by monitoring system."
}
}
PermissionRequest
Purpose: Executed when permission dialogs are displayed to automate decisions or update permissions.
Event-specific fields:
{
"permission_mode": "default | plan | auto_edit | yolo",
"tool_name": "name of the tool requesting permission",
"tool_input": "object containing the tool's input parameters",
"permission_suggestions": "array of suggested permissions (optional)"
}
Output Options:
hookSpecificOutput.decision: structured object with permission decision details:behavior: "allow" or "deny"updatedInput: modified tool input (optional)updatedPermissions: modified permissions (optional)message: message to show to user (optional)interrupt: whether to interrupt the workflow (optional)
Example Output:
{
"hookSpecificOutput": {
"decision": {
"behavior": "allow",
"message": "Permission granted based on security policy",
"interrupt": false
}
}
}
TodoCreated
Purpose: Executed when a new todo item is created via the todo_write tool. Allows validation, logging, or blocking of todo creation.
Todo hooks run in two phases:
validation: runs before persistence. Use this phase for validation only; returningblockordenyprevents the write.postWrite: runs after persistence. Use this phase for side effects such as logging or syncing;blockordenyis ignored in this phase.
Event-specific fields:
{
"todo_id": "unique identifier for the todo item",
"todo_content": "content/description of the todo item",
"todo_status": "pending | in_progress | completed",
"all_todos": "array of all todo items in the current list",
"phase": "validation | postWrite"
}
Output Options:
decision: "allow", "block", or "deny"reason: human-readable explanation for the decision (required when blocking)
Blocking Behavior:
During the validation phase, when decision is block or deny (exit code 2), todo creation is prevented. The todo list remains unchanged, and the reason is provided as feedback to the model.
During the postWrite phase, the todo has already been persisted. Hooks may still return output, but block / deny does not undo the write and should not be used for validation.
Example Output (Allow):
{
"decision": "allow",
"reason": "Todo content validated successfully"
}
Example Output (Block):
{
"decision": "block",
"reason": "Todo content too short. Minimum 5 characters required."
}
Example Hook Script:
#!/bin/bash
# ~/.qwen/hooks/todo-validator.sh
# Validates todo content before creation
INPUT=$(cat)
CONTENT=$(echo "$INPUT" | jq -r '.todo_content')
# Check minimum length
if [ ${#CONTENT} -lt 5 ]; then
echo '{"decision": "block", "reason": "Todo content must be at least 5 characters"}'
exit 2
fi
# Block test-related todos
if [[ "$CONTENT" =~ "test" ]]; then
echo '{"decision": "block", "reason": "Test todos are not allowed in production"}'
exit 2
fi
echo '{"decision": "allow"}'
exit 0
Example Configuration:
{
"hooks": {
"TodoCreated": [
{
"hooks": [
{
"type": "command",
"command": "$HOME/.qwen/hooks/todo-validator.sh",
"name": "todo-validator",
"timeout": 5000
}
]
}
]
}
}
TodoCompleted
Purpose: Executed when a todo item is marked as completed. Allows validation, logging, or blocking of todo completion.
Todo hooks run in two phases:
validation: runs before persistence. Use this phase for validation only; returningblockordenyprevents the write.postWrite: runs after persistence. Use this phase for side effects such as logging or syncing;blockordenyis ignored in this phase.
Event-specific fields:
{
"todo_id": "unique identifier for the todo item",
"todo_content": "content/description of the todo item",
"previous_status": "pending | in_progress (status before completion)",
"all_todos": "array of all todo items in the current list",
"phase": "validation | postWrite"
}
Output Options:
decision: "allow", "block", or "deny"reason: human-readable explanation for the decision (required when blocking)
Blocking Behavior:
During the validation phase, when decision is block or deny (exit code 2), todo completion is prevented. The todo item remains in its previous status, and the reason is provided as feedback to the model.
During the postWrite phase, the todo has already been persisted. Hooks may still return output, but block / deny does not undo the write and should not be used for validation.
Example Output (Allow):
{
"decision": "allow",
"reason": "Todo completion approved"
}
Example Output (Block):
{
"decision": "block",
"reason": "Cannot complete this todo until dependent tasks are finished."
}
Example Hook Script:
#!/bin/bash
# ~/.qwen/hooks/todo-completion-validator.sh
# Validates todo completion conditions
INPUT=$(cat)
TODO_ID=$(echo "$INPUT" | jq -r '.todo_id')
ALL_TODOS=$(echo "$INPUT" | jq -r '.all_todos')
# Check if there are incomplete dependent todos (example logic)
INCOMPLETE_COUNT=$(echo "$ALL_TODOS" | jq '[.[] | select(.status != "completed")] | length')
if [ "$INCOMPLETE_COUNT" -gt 5 ]; then
echo '{"decision": "block", "reason": "Too many incomplete todos. Complete other tasks first."}'
exit 2
fi
echo '{"decision": "allow"}'
exit 0
Example Configuration:
{
"hooks": {
"TodoCompleted": [
{
"hooks": [
{
"type": "command",
"command": "$HOME/.qwen/hooks/todo-completion-validator.sh",
"name": "completion-validator",
"timeout": 5000
}
]
}
]
}
}
Use Cases:
- Logging: Track todo creation and completion for audit or analytics
- Validation: Enforce content quality standards (minimum length, required keywords)
- Workflow Control: Block completion until prerequisites are met
- Integration: Sync todos with external task management systems (Jira, Trello, etc.)
Hook Configuration
Hooks are configured in Qwen Code settings, typically in .qwen/settings.json or user configuration files:
{
"hooks": {
"PreToolUse": [
{
"matcher": "^run_shell_command$",
"sequential": false,
"hooks": [
{
"type": "command",
"command": "/path/to/security-check.sh",
"name": "security-check",
"description": "Run security checks before tool execution",
"timeout": 30000
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "echo 'Session started'",
"name": "session-init"
}
]
}
]
}
}
Hook Execution
Parallel vs Sequential Execution
- By default, hooks execute in parallel for better performance
- Use
sequential: truein hook definition to enforce order-dependent execution - Sequential hooks can modify input for subsequent hooks in the chain
Async Hooks
Only command type supports asynchronous execution. Setting "async": true runs the hook in the background without blocking the main flow.
Features:
- Cannot return decision control (operation has already occurred)
- Results are injected in the next conversation turn via
systemMessageoradditionalContext - Suitable for auditing, logging, background testing, etc.
Example:
{
"hooks": {
"PostToolUse": [
{
"matcher": "write_file|edit",
"hooks": [
{
"type": "command",
"command": "$QWEN_PROJECT_DIR/.qwen/hooks/run-tests-async.sh",
"async": true,
"timeout": 300000
}
]
}
]
}
}
#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ "$FILE_PATH" != *.ts && "$FILE_PATH" != *.js ]]; then exit 0; fi
RESULT=$(npm test 2>&1)
if [ $? -eq 0 ]; then
echo "{\"systemMessage\": \"Tests passed after editing $FILE_PATH\"}"
else
echo "{\"systemMessage\": \"Tests failed: $RESULT\"}"
fi
Security Model
- Hooks run in the user's environment with user privileges
- Project-level hooks require trusted folder status
- Timeouts prevent hanging hooks (default: 60 seconds)
Best Practices
Example 1: Security Validation Hook
A PreToolUse hook that logs and potentially blocks dangerous commands:
security_check.sh
#!/bin/bash
# Read input from stdin
INPUT=$(cat)
# Parse the input to extract tool info
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name')
TOOL_INPUT=$(echo "$INPUT" | jq -r '.tool_input')
# Check for potentially dangerous operations
if echo "$TOOL_INPUT" | grep -qiE "(rm.*-rf|mv.*\/|chmod.*777)"; then
echo '{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Security policy blocks dangerous command"
}
}'
exit 2 # Blocking error
fi
# Log the operation
echo "INFO: Tool $TOOL_NAME executed safely at $(date)" >> /var/log/qwen-security.log
# Allow with additional context
echo '{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "Security check passed",
"additionalContext": "Command approved by security policy"
}
}'
exit 0
Configure in .qwen/settings.json:
{
"hooks": {
"PreToolUse": [
{
"hooks": [
{
"type": "command",
"command": "${SECURITY_CHECK_SCRIPT}",
"name": "security-checker",
"description": "Security validation for bash commands",
"timeout": 10000
}
]
}
]
}
}
Example 2: HTTP Audit Hook
A PostToolUse HTTP hook that sends all tool execution records to a remote audit service:
{
"hooks": {
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "http",
"url": "https://audit.example.com/api/tool-execution",
"headers": {
"Authorization": "Bearer ${AUDIT_API_TOKEN}",
"Content-Type": "application/json"
},
"allowedEnvVars": ["AUDIT_API_TOKEN"],
"timeout": 10,
"name": "audit-logger"
}
]
}
]
}
}
Example 3: User Prompt Validation Hook
A UserPromptSubmit hook that validates user prompts for sensitive information and provides context for long prompts:
prompt_validator.py
import json
import sys
import re
# Load input from stdin
try:
input_data = json.load(sys.stdin)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON input: {e}", file=sys.stderr)
exit(1)
user_prompt = input_data.get("prompt", "")
# Sensitive words list
sensitive_words = ["password", "secret", "token", "api_key"]
# Check for sensitive information
for word in sensitive_words:
if re.search(rf"\b{word}\b", user_prompt.lower()):
# Block prompts containing sensitive information
output = {
"decision": "block",
"reason": f"Prompt contains sensitive information '{word}'. Please remove sensitive content and resubmit.",
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit"
}
}
print(json.dumps(output))
exit(0)
# Check prompt length and add warning context if too long
if len(user_prompt) > 1000:
output = {
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "Note: User submitted a long prompt. Please read carefully and ensure all requirements are understood."
}
}
print(json.dumps(output))
exit(0)
# No processing needed for normal cases
exit(0)
Troubleshooting
- Check application logs for hook execution details
- Verify hook script permissions and executability
- Ensure proper JSON formatting in hook outputs
- Use specific matcher patterns to avoid unintended hook execution
- Use
--debugmode to see detailed hook matching and execution information - Temporarily disable all hooks: add
"disableAllHooks": truein settings