Skip to main content

Hook Data Contract

Hooks run custom checks at defined points in an agent loop. This page documents the JSON that hooks receive, the JSON they may return, and the values that are safe to code against.

For what hooks are and when to use them, see Agent Hooks. To create one, see Create and Manage Hooks (Portal) or Configure Agent Hooks (API).

The short version:

  • Command hooks receive one JSON object on stdin. To pass, write a HookResult JSON object or nothing at all. To block, reject in that JSON or exit 2 — on every event except Start, which reads only additionalContext.
  • Prompt hooks receive the same input through $ARGUMENTS and may return only ok and reason.
  • Tool arguments are under tool_input. Their keys come from the selected tool's schema, so there is no universal list of tool-argument keys.
  • A parent hook can identify a requested sub-agent from Task or Agent inputs. Child loops run effective system and global activity hooks plus the child agent's own hooks; the parent agent's agent-scoped hooks are not copied. Start-hook scope is described below.
  • Hooks see top-level tool calls, not every file, process, or network operation performed inside a shell, Python, or nested agent call.

Events

EventWhen it runsEvent-specific inputEffective output
StartOnce per top-level thread; once per child conversation only for hooks configured directly on that childstart_message, thread_sourceCommand-hook additionalContext can seed the run. Prompt-hook output has no useful Start effect.
PreToolUseBefore a tool executesTool identity, arguments, mode, annotations, and call_idPass, block, ask for confirmation, explicitly allow, or add context. Resumed tools skip this event.
PostToolUseAfter a tool returnsTool identity, arguments, result, success flag, and call_idPass, block the returned result, pause post-execution, or add context. It cannot undo an external side effect that already occurred.
StopWhen the loop is about to stopstop_hook_active, stop_rejection_count, final_outputok: false keeps the loop running. Include a useful reason; a missing one is replaced with a placeholder.

PreToolUse is the enforcement point for stopping a tool before execution. PostToolUse is useful for checking, blocking, or flagging returned content, not for preventing the operation. It cannot rewrite tool_result — the result is either passed through whole or blocked and replaced by the hook's reason.

Input Contract

Command hooks receive the input as JSON on stdin. Prompt hooks receive the same JSON in place of $ARGUMENTS; if the prompt omits that placeholder, the runtime appends the JSON to the prompt under a Context: heading.

Common keys

KeyTypeAvailabilityMeaning
hook_event_namestringAll eventsStart, PreToolUse, PostToolUse, or Stop. Use this instead of expecting a context_type key.
agent_namestringAll eventsAgent loop that owns this hook invocation. On the deployments tested the top-level loop reported meta_agent, but treat that as a default to confirm rather than a constant — read the value from your own trace before matching on it.
current_turnintegerAll eventsCurrent loop turn. Start uses 0.
max_turnsintegerAll eventsTurn limit for the run.
execution_summarystring or nullAll eventsPath to a file holding the run's JSON trajectory. It is null at Start and is never the transcript itself. Reliable only for the first hook that runs on an event — see chained hooks.
Additional metadata keysJSON valuesWhen the caller supplies themFlattened into the top-level object. Treat unknown keys as optional and untrusted.

Example:

{
"hook_event_name": "Stop",
"agent_name": "meta_agent",
"current_turn": 8,
"max_turns": 250,
"execution_summary": "/mnt/data/hook_transcript_0ead3abf-...-42d4e257da47_fa5919574939.txt"
}

Start keys

KeyTypeMeaning
start_messagestring or nullInitial message that started the thread.
thread_sourcestring or nullSource such as Conversation, Alert, or Incident. Treat it as an open string and handle unknown values.

PreToolUse keys

KeyTypeMeaning
tool_namestringRegistered tool name used for matching.
tool_inputobject or nullArguments selected for this call. Keys are defined by that tool.
tool_descriptionstring or nullRegistered description, when available.
agent_modestringCurrent mode: Review, Autonomous, or ReadOnly. See Run Modes.
is_write_actionbooleanTrue only when the tool carries the WriteAction annotation. It is not a complete mutability guarantee.
requires_approvalbooleanTrue when the tool carries RequiresApproval or is in the runtime's approval-required name list.
requires_browser_connectionbooleanTrue when the tool carries RequiresBrowserConnection.
call_idstringUnique ID for this tool invocation.

PostToolUse keys

KeyTypeMeaning
tool_namestringRegistered tool name used for matching.
tool_inputobject or nullOriginal arguments for the call.
tool_resultanyTool return value received by the hook. Normally the full result rather than the shortened copy sent to the model, though a large result — most often from an MCP tool — can arrive already shortened. Do not assume an audit or content check sees the complete output.
tool_succeededbooleanWhether invocation completed without an exception. A tool that returns an error message as its result still reports true, so inspect tool_result before treating this as "the work succeeded".
call_idstringThe same ID supplied at PreToolUse for this call.

Metadata produced by PreToolUse or the execution pipeline is flattened into the top level of the PostToolUse object. See Ordering and aggregation for when it is dropped, and Custom metadata names for how a hook-supplied key can shadow a field in this table.

A tool that pauses for an approval or a user question finishes on a resumed call. The payload has no separate resumed-call indicator. On that path, current_turn is 0, max_turns is 250, and tool_result may arrive as a serialized string rather than its original type. Do not treat 0 and 250 as a resume detector or base policy on those turn fields; type-check tool_result.

Stop keys

KeyTypeMeaning
stop_hook_activebooleanWhether a prompt Stop hook has already rejected stopping in this run.
stop_rejection_countintegerNumber of prior prompt-hook rejections. Command-hook rejections do not increment it.
final_outputstring or nullResponse the agent is about to return.

Example:

{
"hook_event_name": "PreToolUse",
"agent_name": "meta_agent",
"current_turn": 4,
"max_turns": 250,
"execution_summary": "/mnt/data/hook_transcript_0ead3abf-...-42d4e257da47_fa5919574939.txt",
"tool_name": "ReadFile",
"tool_input": {
"filePath": "/mnt/data/codeRefs/service/src/Program.cs",
"offset": 1,
"limit": 80
},
"tool_description": "Read a file",
"agent_mode": "Review",
"is_write_action": false,
"requires_approval": false,
"requires_browser_connection": false,
"call_id": "call_01H..."
}

Tool input schemas

These representative schemas are starting points, not a substitute for the registered tool schema in your environment. Confirm the real keys in the trace before you rely on them — see Debugging real values.

ToolSelected input keysWhat a hook can infer
ReadFilefilePath, offset, limitDeclared file and requested range
CreateFilefilePath, contentDeclared destination and content
ReplaceStringInFilefilePath, oldString, newStringDeclared edit
MultiReplaceStringInFileexplanation, replacementsEach replacement contains its own filePath, oldString, and newString
GrepSearchquery, isRegexp, includePattern, maxResults, includeIgnoredFilesDeclared text search
FileSearchquery, maxResultsDeclared glob search
ListDirpathDeclared directory listing
RunInTerminalcommand, isBackgroundCommand string, not the command's eventual file or network effects; this includes Python invoked through the terminal
Tasksubagent_type, prompt, description, optional max_turnsRequested task sub-agent
Agentsubagent_type, prompt, context, description, name, model, run_in_backgroundRequested Agent-tool sub-agent
read_skill_fileskill_name, file_pathRequested skill and relative file; normal activation starts with SKILL.md

Important current behavior:

  • Task.max_turns defaults to 50 and currently has no runtime upper bound. Validate it in a PreToolUse hook if users can influence it.
  • Agent.subagent_type is missing from the tool schema's required list, but the runtime still requires it. Agent currently runs for up to 100 turns.
  • Agent.model is reserved but ignored, and run_in_background currently executes in the foreground.
  • The registered directory-listing tool is ListDir, not ListDirectory.

Values not directly exposed

Do not assume the hook payload contains:

  • the thread ID (and do not parse one out of execution_summary);
  • the hook's own configured name;
  • the active skill set;
  • a model or deployment name;
  • every operation performed inside a shell or Python call;
  • the current activity of a nested child agent;
  • a dedicated resumed-call indicator;
  • a reliable list of files read or changed.

Tool telemetry records SkillsLoaded and SkillSource, but those are telemetry dimensions, not hook-input keys.

Output Contracts

Prompt and command hooks do not share the same output schema.

Prompt hooks

A prompt hook should return exactly one JSON object matching this shape:

{"ok": true, "reason": null}

or:

{"ok": false, "reason": "Fixed, actionable explanation."}

Only ok and reason are consumed. Prompt hooks cannot return hookSpecificOutput, additionalContext, metadata, or permission decisions. A response that omits ok, uses decision instead, or is not valid JSON is accepted as a pass. On tool and Stop events, ok: false rejects even when reason is missing or empty; the runtime then supplies:

Hook rejected the action without providing a reason.

Start is the exception: the runtime discards ok and reason there, so a prompt Start hook cannot reject anything and has no useful output at all.

Prompt hooks are given ReadFile and GrepSearch only when the invocation had a non-empty execution_summary to save as a transcript. Start hooks, which have no execution summary, get no tools. Those tools are instruction-scoped rather than confined to the transcript, so a prompt hook can read other workspace files they can reach.

execution_summary in chained hooks

Each executor rewrites execution_summary on the shared context in place before it runs: the prompt executor writes the transcript to a file and replaces the value with that path, then deletes the file when it finishes; the command executor uploads the value to the sandbox and replaces it with the uploaded path. Every hook on an event receives that same context object, and Stop runs its prompt chain and its command chain over one context.

So only the first hook to run sees a path to the trajectory. A second hook on the same event receives a path to a file whose contents are the previous hook's path string, or a path to a file the prompt executor already deleted.

If you configure more than one hook on an event, treat execution_summary as best-effort: check the file exists and parses as JSON before relying on it, and fall back to the payload keys rather than failing.

summary = context.get("execution_summary")
trajectory = None
if isinstance(summary, str) and os.path.isfile(summary):
try:
with open(summary) as fh:
trajectory = json.load(fh)
except (ValueError, OSError):
trajectory = None # chained hook, deleted file, or path-to-a-path

A single hook on an event is unaffected.

These are hook-internal tools, not the workspace tools of the same name in the tool input table above. They are a separate implementation with their own parameters, and they are called by the hook's own model — they never appear as tool_name in a payload your hook receives.

Command hooks

A command hook has three ways to report a decision:

Exit codestdoutResult
0HookResult JSONThe JSON decides.
0empty or whitespacePass.
2ignoredBlocks. stderr becomes the reason. failMode does not apply.
otherignoredProcess failure; failMode decides.

Exit 2 is the simplest way to block from a script that has nothing else to say. Neither path blocks on Start — that event keeps additionalContext and discards every rejection signal, whether it arrived as JSON or as an exit code. Everything else uses the JSON contract:

{
"ok": true,
"reason": null,
"hookSpecificOutput": {
"permissionDecision": null,
"permissionDecisionReason": null,
"additionalContext": null
}
}
KeyTypeMeaning
okbooleanPass when true, reject when false. A command hook that supplies a whitespace-only reason does not block; an omitted or empty one is replaced and does block — see the caveat below.
reasonstring or nullUser-facing rejection reason. A deny reads this top-level field.
hookSpecificOutput.permissionDecisionstring or nullallow, deny, or ask; see event behavior below.
hookSpecificOutput.permissionDecisionReasonstring or nullPreferred question text for ask; it is not the deny reason.
hookSpecificOutput.additionalContextstring or nullContext added by the hook; see Delivering additionalContext.
Other keys inside hookSpecificOutputJSON valuesCustom metadata. These keys can be carried to a later event; there is no nested metadata wrapper in the wire format. Never reuse a payload field name — see Custom metadata names.

systemMessage, suppressOutput, continue, stopReason, hookSpecificOutput.hookEventName, and hookSpecificOutput.updatedInput are accepted by the model for compatibility, but current hook consumers do not implement their apparent effects. Do not build new hooks around them.

Legacy decision: "block" still maps to rejection and takes precedence over ok; any other decision value maps to a normal pass, not a permission allow. Prefer ok plus permissionDecision where applicable.

A whitespace-only reason does not block

Omitting reason entirely is safe — a command hook returning {"ok": false} is rejected and reported as Blocked by hook.

A whitespace-only reason is not. {"ok": false, "reason": " "} is currently treated as no rejection at all: the default-reason substitution does not replace it, and the aggregation step then discards it for having no usable reason. On a Stop hook the agent stops; elsewhere the chain can pass.

Always emit a non-whitespace reason, and guard against a template or unset variable rendering to spaces. This is a known defect, tracked internally; this section describes today's behavior.

Custom metadata names

Custom keys are flattened into the top level of the later payload, so a key that collides with a contract field emits two entries with that name — the real one first, the hook-supplied one second. Python's json, jq, and JSON.parse all keep the last, so an earlier hook can shadow the tool_name or tool_input a later audit or policy hook reads.

Prefix your keys and never reuse any field name from the input contract — the common keys every event carries, or any event-specific key such as those for PreToolUse and PostToolUse. Custom keys land on the same top-level object as hook_event_name, agent_name, current_turn, max_turns, and execution_summary, so those shadow just as readily as tool_name:

{
"ok": true,
"hookSpecificOutput": {
"contoso_policy_version": "2025-01",
"contoso_classification": "reference"
}
}

These reappear as top-level contoso_policy_version and contoso_classification in the matching PostToolUse payload.

If your hook makes decisions on a contract field, read the first occurrence instead of the last — the pipeline's value is always written before hook metadata:

import json, sys

# Take the first occurrence of each key, so hook-supplied metadata
# cannot shadow a pipeline-set field.
payload = json.loads(
sys.stdin.read(),
object_pairs_hook=lambda pairs: {k: v for k, v in reversed(pairs)},
)

Normal pass:

{"ok": true}

Block a tool before execution:

{
"ok": false,
"reason": "This operation is not permitted.",
"hookSpecificOutput": {
"permissionDecision": "deny"
}
}

Ask the user:

{
"ok": true,
"hookSpecificOutput": {
"permissionDecision": "ask",
"permissionDecisionReason": "Allow this production operation?"
}
}

Add fixed context and continue through normal permission handling:

{
"ok": true,
"hookSpecificOutput": {
"additionalContext": "Treat this source as read-only reference material."
}
}

Avoid returning permissionDecision: "allow" unless bypassing normal platform permission evaluation is intentional. A plain {"ok": true} passes the hook without granting that bypass. See Tool Access Policies for what an allow bypasses.

Permission decisions are primarily a PreToolUse contract. PostToolUse also honors deny by blocking the returned result and ask by pausing after execution; the operation has already happened. allow has no permission bypass to perform after execution.

When a child cannot request approval, a PostToolUse ask blocks the result and withholds the original tool output, tool-generated messages, hook context, and metadata. The child receives only: Tool executed, but its output was withheld as it needs user approval. Try to find an alternative approach.

Start and Stop differ from each other, and from the tool events:

  • Stop rejects with ok: false and a non-blank reason.
  • Start uses neither. The runtime reads only additionalContext from a Start hook and discards ok and reason, so a Start hook returning {"ok": false, "reason": "..."} does not prevent the thread from starting and the reason goes nowhere. A Start hook's only useful output is context. For the same reason, do not send a permissionDecision from a Start hook: allow, deny, and ask all suppress additionalContext (see the table below), and since context is a Start hook's only output, pasting a tool-event response shape into one leaves it doing nothing at all.

Delivering additionalContext

On tool events, additionalContext becomes a user message appended to the calling agent's conversation, immediately after the tool result. It does not rewrite tool_input, and it cannot reach a child agent launched by Task or Agent — that child runs its own conversation.

Whether it survives depends on how the chain ends:

Chain outcomeadditionalContext delivered
Pass (ok: true, no permission decision)Yes
Reject (ok: false)Yes, alongside the block
allowNo
denyNo
askNo

Blocking and explaining in the same response is therefore supported, but only through a plain ok: false rejection rather than permissionDecision: "deny". To steer a child agent, put the instruction in that agent's own configuration.

Ordering and Aggregation

Matching hooks run in this order:

  1. System-global hooks
  2. Agent hooks
  3. User-global hooks

The first deny or ask stops the chain immediately. This means an agent hook that returns ask can prevent a later user-global deny hook from running. Explicit allow does not stop later hooks, but if no later hook rejects, it can bypass normal permission evaluation.

A later ask also discards earlier plain rejections. Plain ok: false results accumulate as reasons rather than ending the chain, and ask is checked before those accumulated reasons are applied. So if an earlier hook returns {"ok": false, "reason": "..."} and any later hook returns ask, the chain returns the ask and the user can approve the tool — the earlier rejection never surfaces as a block.

Use permissionDecision: "deny" for a block that must not be downgraded. deny short-circuits the chain, so no later hook can turn it into a prompt. A plain ok: false is the right choice for explaining a rejection, not for enforcing one that has to hold.

PreToolUse is not rerun after an approved pause, so a later hook skipped by ask does not get another chance to deny. Do not make a later-tier hook the only control for a sensitive action; use platform permissions or external controls for hard enforcement.

Stop is evaluated as two chains: all matching prompt hooks first, then all matching command hooks. Each chain preserves the tier order above.

When multiple passing hooks return data:

  • the last non-empty additionalContext wins;
  • the first value for each metadata key wins;
  • one hook's output is not injected into the next hook in the same event;
  • PreToolUse metadata carries into PostToolUse, except across an approval or default-approval pause, which drops it.

The pipeline reserves cliExecution, approval, browser, userQuestion, and _aggregation.allowing_hook. A genuine pipeline value for one of those keys overwrites a hook-supplied value. If the pipeline has no value, a hook-supplied key can survive, so do not use these names for custom metadata.

Metadata is flattened, not nested, so a key matching a contract field such as tool_name is emitted alongside the real one and shadows it for parsers that keep the last duplicate. See Custom metadata names.

A blocked tool reaches the model as wrapped text. PreToolUse produces:

[Tool execution blocked] <reason>

PostToolUse currently produces both wrappers:

[Tool result blocked by hook] [Hook Blocked Tool Result] <reason>

Sub-Agents and Skills

What the parent hook can identify

Task requires subagent_type — its schema rejects a call without one. See Custom Agents for how these are defined.

Agent does not. Its schema requires only prompt, context, description, and name; subagent_type is checked inside the tool, which runs after PreToolUse. So a PreToolUse hook can receive an Agent payload with subagent_type missing, empty, or an unknown value, even though the launch later fails. Default-deny that case rather than assuming the key exists.

tool_input.name on Agent is a runtime instance name. It is not a substitute for subagent_type.

The hook's own agent_name identifies the loop that is running the hook. It does not change to the requested child merely because tool_name is Task or Agent.

What comes back

A launch tool's PostToolUse tool_result is a structured object rather than a string, so a hook can review the child run after it finishes:

{
"tool_name": "Task",
"tool_succeeded": true,
"tool_result": {
"status": "completed",
"subagent_type": "Bash",
"description": "Check disk usage",
"content": "Filesystem usage is 41%.",
"total_duration_ms": 8462,
"total_tool_use_count": 1,
"max_turns_reached": false,
"turn_limit": null
}
}

turn_limit is null unless the child actually hit its turn ceiling; it carries the limit only when max_turns_reached is true. Test for max_turns_reached rather than for the presence of turn_limit.

total_tool_use_count and max_turns_reached are useful for auditing how much work a child did. Effective global hooks also observe the child's individual tool calls; parent agent-scoped hooks observe only the launch unless the child defines the same hook itself. Read the fields defensively; tool_result shape is tool-specific and is not part of a stable contract.

Child inheritance and user interaction

The parent loop's PreToolUse hook can approve or block the launch request. Children inherit the parent thread's effective run mode and global/system PreToolUse and PostToolUse hooks. They also run their own agent hooks. They do not inherit the parent agent's agent-scoped hooks.

Start and Stop hooks configured directly on the child agent run in the child conversation. Global and system Start and Stop hooks are not inherited. Child hook enforcement is recorded in telemetry, but child hook lifecycle cards are not streamed in the parent thread until those events can carry child execution correlation.

Child loops cannot yet bubble approval, browser, authorization, or question requests to the parent. Hook ask decisions are therefore blocked instead of suspended. Permission-rule ask follows the inherited mode: Autonomous allows it, while Review and ReadOnly children block because they cannot suspend. Children can use an existing connected browser session, but cannot request a new connection. Runtime-owned in-process hooks receive this capability internally; it is not part of the external hook payload. Permission bubbling is planned as a separate runtime capability.

Skill visibility

Loading a skill is a normal tool call:

{
"tool_name": "read_skill_file",
"tool_input": {
"skill_name": "incident-triage",
"file_path": "SKILL.md"
}
}

A PreToolUse hook can allow or deny that load. Skill lookup is case-insensitive, so normalize the value before comparing it. The hook does not receive a field containing every active skill, and it does not observe instructions already present in the agent's initial context as a skill-load event.

Filesystem and Repository Visibility

Hooks observe declared top-level tool calls, not operating-system syscalls.

For direct workspace tools such as ReadFile or CreateFile, inspect the path key from that tool's schema. Guard the lookup, because tool_input can be absent or explicitly null:

tool_input = context.get("tool_input")
path = tool_input.get("filePath") if isinstance(tool_input, dict) else None

Do not generalize that to "all files touched":

  • RunInTerminal can read or modify many files while the hook sees only tool_input.command. Running a Python script this way does not expose the script file's contents or eventual effects to the hook.
  • A child agent can select tools after the parent launch. Effective global hooks observe those tool calls, while the parent agent's agent-scoped hooks do not.
  • is_write_action, requires_approval, and requires_browser_connection are annotation- or registration-derived hints. Some mutating tools currently report is_write_action: false.

/mnt/data is the hook and tool working area, not a filesystem security boundary. Command hooks share the tool sandbox and run as the same user as tool code, so a malicious tool command can inspect or tamper with hook scripts and transcript files there. Use platform permissions and external controls for hard isolation.

Do not assume one sandbox. Depending on which tool sets are enabled, workspace tools such as ReadFile and RunInTerminal can be rooted somewhere other than /mnt/data, and ReadFile rejects paths outside its own root. A path that is valid for one tool can be unreachable from another, including the path in execution_summary. Read the real path from the trace instead of hard-coding a root.

Repository values are likewise tool-specific. A pull-request tool may expose repository, pull_number, and related keys, but the hook contract does not define one universal repository object. Inspect the real tool_input, and canonicalize repository URLs or paths before enforcing a boundary. String prefix checks are not safe containment checks.

Debugging Real Values

Do not write a matcher or argument parser from memory. Capture one real call, then code against the exact tool name and keys you observed.

Trace view

Open the thread's View trace panel and select an outbound tool-use span. The span name identifies tool_name; its input and output show the tool arguments and result. Display values can be truncated or redacted, so fall back to raw telemetry when something looks incomplete. The panel reads from Application Insights, so it reports a not-configured error in deployments without it. See Debug Trace for how to open and read the panel.

Answer these before writing a hook:

  1. What is the exact tool_name?
  2. Which keys are present in tool_input?
  3. Is the path absolute, sandbox-relative, or repository-relative?
  4. Which agent_name or SubAgentName appears for this run?
  5. Is the value absent, empty, redacted, or merely truncated?

Raw tool telemetry in Application Insights

These dimensions are diagnostic telemetry, not the hook payload. The names differ from the payload keys and are not part of the contract above — code against the input and output tables, and use these queries to discover the real values a hook will see.

These queries filter on a thread ID, which is the ThreadId dimension on that thread's telemetry rows. Hook payloads do not carry it, so take it from the thread you are inspecting. To explore without one, drop the ThreadId line and filter on a recent time window and ToolName instead.

AgentToolExecution records tool starts and ends. It uses SubAgentName; hook events use AgentName.

customEvents
| where name == "AgentToolExecution"
| extend d = customDimensions
| where tostring(d.ThreadId) == "<thread-id>"
| project
timestamp,
EventType = tostring(d.EventType),
ToolName = tostring(d.ToolName),
SubAgentName = tostring(d.SubAgentName),
CallId = tostring(d.CallId),
ToolInput = tostring(d.ToolInput),
ToolOutput = tostring(d.ToolOutput),
SkillsLoaded = tostring(d.SkillsLoaded),
SkillSource = tostring(d.SkillSource)
| order by timestamp asc

Tool start and end rows share CallId, so this event can correlate a tool's input and output. Treat ToolInput as sensitive — it carries full command arguments, queries, and file paths, so redact it before sharing or exporting results. Tool output is truncated and can be redacted for customer telemetry. SkillsLoaded is a comma-separated active-skill snapshot and SkillSource is a corresponding comma-separated source list; extended skill names are redacted for third-party tenants. Rows for provider-native server tools omit both skill fields.

Raw hook telemetry in Application Insights

AgentHookEvaluation summarizes one hook chain:

customEvents
| where name == "AgentHookEvaluation"
| extend d = customDimensions
| where tostring(d.ThreadId) == "<thread-id>"
| project
timestamp,
HookEventType = tostring(d.HookEventType),
ToolName = tostring(d.ToolName),
AgentName = tostring(d.AgentName),
FinalDecision = tostring(d.FinalDecision),
HooksConfigured = toint(d.HooksConfigured),
HooksRun = toint(d.HooksRun),
SystemHooksRun = toint(d.SystemHooksRun),
AgentHooksRun = toint(d.AgentHooksRun),
UserGlobalHooksRun = toint(d.UserGlobalHooksRun),
AllowingHook = tostring(d.AllowingHook),
RejectionCount = toint(d.RejectionCount),
DurationMs = tolong(d.DurationMs)
| order by timestamp asc

AgentHookExecution records individual non-pass decisions:

customEvents
| where name == "AgentHookExecution"
| extend d = customDimensions
| where tostring(d.ThreadId) == "<thread-id>"
| project
timestamp,
HookName = tostring(d.HookName),
HookType = tostring(d.HookType),
HookEventType = tostring(d.HookEventType),
Tier = tostring(d.Tier),
ToolName = tostring(d.ToolName),
AgentName = tostring(d.AgentName),
Decision = tostring(d.Decision),
Reason = tostring(d.Reason),
ExceptionType = tostring(d.ExceptionType),
DurationMs = tolong(d.DurationMs)
| order by timestamp asc

Interpret these events carefully:

  • AgentHookEvaluation is emitted only when at least one hook matched and ran.
  • No row can mean hooks were disabled, absent, inactive, source-filtered, unmatched, or that telemetry failed.
  • AgentHookExecution suppresses ordinary pass decisions.
  • FinalDecision values are pass, allow, reject, deny, and ask. Individual Decision values additionally include error; pass rows are suppressed.
  • Tier values are system, agent, and user-global.
  • The counts include hooks you did not configure. A single user global hook on PreToolUse can still report HooksConfigured: 3 because built-in system hooks run alongside it. Read UserGlobalHooksRun for your own hooks.
  • RejectionCount counts reject decisions only. A deny blocks the call but leaves RejectionCount at 0.
  • Reason carries a command hook's stderr on a reject. A deny returned through permissionDecisionReason does not populate it, so read Decision rather than Reason to tell whether a call was blocked.
  • ExceptionType is populated only when the hook threw. It is empty on ordinary decision rows, so the column being blank is normal.
  • Hook events do not contain CallId. Do not claim exact correlation to an AgentToolExecution row; use thread, tool, event, and timestamp as clues.
  • Third-party customer telemetry truncates hook reasons to 256 characters and appends an ellipsis, so a 257-character value indicates truncation.

For a safe payload-discovery hook, log a fixed allowlist of key names rather than values. Never copy model-controlled values into a rejection reason, additionalContext, or an external log without validation and redaction.

Configuration

This is the deployable YAML shape for a customer-persisted global hook:

api_version: azuresre.ai/v2
kind: Hook
metadata:
name: block-restricted-skill
spec:
eventType: PreToolUse
activationMode: always
description: Block one restricted skill
hook:
type: command
matcher: read_skill_file
timeout: 30
failMode: block
script: |
#!/bin/bash
set -euo pipefail

context="$(cat)"
skill="$(jq -r '(.tool_input.skill_name // "") | ascii_downcase' <<<"$context")"

if [[ "$skill" == "restricted-runbook" ]]; then
jq -nc '{
ok: false,
reason: "This skill is not permitted.",
hookSpecificOutput: {permissionDecision: "deny"}
}'
else
jq -nc '{ok: true}'
fi

Create or update the hook with the hooks API, then confirm registration with GET /api/v2/extendedAgent/hooks.

Hooks scoped to a single agent are configured in the portal or through the REST API. The spec.hook block below is the same for global and agent-scoped hooks.

Configuration keys:

KeyValuesNotes
metadata.namestringRequired. Letters, digits, hyphen, underscore; cannot start with a reserved system prefix
metadata.ownerstringOptional owner
metadata.tagsstring arrayOptional tags
spec.eventTypeStart, PreToolUse, PostToolUse, StopRequired. Event to observe
spec.activationModealways, onDemandRequired. On-demand hooks must be activated for the thread
spec.descriptionstringOptional human-readable description
spec.hook.typecommand, promptRequired.
spec.hook.matcherregular expressionMatches tool_name. Use * for every tool. Required by validation only for PostToolUse, but a PreToolUse hook saved without one matches nothing and never fires, so set it for both tool events
spec.hook.commandstringSingle command. A command hook needs exactly one of command or script
spec.hook.scriptstringMulti-line Bash or Python script, up to 64 KB. Must start with #!/bin/bash or #!/usr/bin/env python3
spec.hook.promptstringRequired for prompt hooks. Place $ARGUMENTS where the input JSON should appear
spec.hook.modelmodel scenario or deployment namePrompt-hook model override
spec.hook.timeoutinteger secondsExecution timeout; defaults to 30
spec.hook.failModeallow, blockCommand-process failure behavior
spec.hook.maxRejectionsinteger from 1 to 25Prompt Stop-hook rejection limit

The REST global-hook API accepts sources for Start hooks and rejects it on other events.

Matcher behavior:

  • tool events match against tool_name;
  • Start and Stop do not use tool matching;
  • * matches every tool;
  • other values are case-sensitive regular expressions anchored to the full tool name;
  • empty matchers match nothing;
  • invalid or timed-out regular expressions fall back to a case-sensitive exact string comparison.

Only one case is checked at apply time: a global PostToolUse hook whose matcher is not * must compile as a regular expression. Every other combination — global PreToolUse, and any matcher embedded in agent YAML — is stored unverified, so a bad pattern surfaces at runtime as the exact-string fallback above.

For an enforcement hook, a malformed regular expression will usually match no real tool name and fail open. Validate every matcher before applying it, then test it against the exact tool names observed in the trace.

Apply replaces the whole spec rather than merging into it. Omitting matcher when re-applying an existing hook clears it, which silently disables a PreToolUse hook that worked before. Keep the matcher in the applied YAML.

failMode governs command-hook process failures such as non-zero exit, timeout, or invalid command output. It does not turn prompt-hook malformed JSON into a rejection, and it is not protection against another process in the shared sandbox tampering with the hook.

When a Hook Does Not Fire

Work down this list; each step rules out the one above it.

  1. Event. Confirm the event you configured actually occurs for the action you are testing.
  2. eventType. Values are parsed case-insensitively, so pretooluse and PreToolUse both resolve. An unrecognized value is rejected on apply. Tool name matching, by contrast, is case-sensitive.
  3. Activation. An on-demand hook runs only on threads where it has been activated. Open the thread's hook controls and confirm it is active.
  4. Registration. Confirm the hook exists and is enabled with GET /api/v2/extendedAgent/hooks.
  5. Matcher. Both tool events match the matcher against the tool name as a regular expression. A tool hook saved without a matcher never fires. Verify the exact name in the trace rather than assuming it.
  6. An earlier hook short-circuited. A deny or ask from a higher tier stops the chain. Query AgentHookEvaluation for FinalDecision and HooksRun.
  7. It ran and passed. AgentHookExecution suppresses pass rows, so a chain that ran cleanly leaves no per-hook row.
  8. It ran and failed. Look for Decision == "error" and ExceptionType. A failing process is governed by failMode, and failMode: allow makes a broken hook look like an absent one.

Examples

These examples use fixed policy messages, default-deny unknown variants, and only documented fields.

Gate custom sub-agents and bound Task turns

Use this command script in a PreToolUse hook with matcher:

^(Task|Agent)$
#!/usr/bin/env python3
import json
import sys

context = json.load(sys.stdin)
tool = context.get("tool_name", "")
tool_input = context.get("tool_input")
if not isinstance(tool_input, dict):
tool_input = {}

allowed = False

if tool == "Task":
raw_target = tool_input.get("subagent_type")
target = raw_target.casefold() if isinstance(raw_target, str) else ""
raw_max_turns = tool_input.get("max_turns", 50)
max_turns = (
raw_max_turns
if isinstance(raw_max_turns, int) and not isinstance(raw_max_turns, bool)
else 0
)

allowed = target in {"explore", "codereview"} and 1 <= max_turns <= 100
elif tool == "Agent":
# subagent_type is not schema-required here, so a missing or non-string
# value falls through to "" and is denied.
raw_target = tool_input.get("subagent_type")
target = raw_target.casefold() if isinstance(raw_target, str) else ""
allowed = target in {"readonly-analyst", "incident-reviewer"}

if allowed:
print(json.dumps({"ok": True}))
else:
print(json.dumps({
"ok": False,
"reason": "This sub-agent request is not permitted.",
"hookSpecificOutput": {"permissionDecision": "deny"},
}))

Replace the sample allowlist values with the exact built-in or custom names shown in your trace. Current Task built-ins are Explore, Plan, CodeReview, KustoQuery, Bash, and DocsGuide, plus registered extended agents. Current Agent built-ins are Explore, Plan, CodeReview, Bash, Verification, and GeneralPurpose, plus registered extended agents.

If any agent sets a custom toolName, the matcher above will not match it. Add that exact tool name to both the matcher and the script, or the launch is not gated.

This gate controls only the launch. See Child inheritance and user interaction for what the parent hook cannot see afterward.

Adjust behavior for temporary files and code references

This example branches on declared path arguments. It is useful for routing behavior, not for proving filesystem containment.

Use a PreToolUse matcher for direct file tools:

^(ReadFile|CreateFile|ReplaceStringInFile)$
#!/usr/bin/env python3
import json
import sys

context = json.load(sys.stdin)
tool_input = context.get("tool_input")
if not isinstance(tool_input, dict):
tool_input = {}

path = str(tool_input.get("filePath", "")).replace("\\", "/")
normalized = "/" + path.lstrip("/")

if "/codeRefs/" in normalized:
result = {
"ok": True,
"hookSpecificOutput": {
"additionalContext": (
"Treat code reference content as read-only source material."
)
},
}
elif normalized.startswith("/mnt/data/"):
result = {
"ok": True,
"hookSpecificOutput": {
"additionalContext": (
"Treat this path as temporary workspace data, not durable storage."
)
},
}
else:
result = {"ok": True}

print(json.dumps(result))

Copy the real path form from the trace before adapting this example. If the classification controls access rather than guidance, canonicalize the path, resolve symlinks, and enforce it outside the shared sandbox as well.

Block one skill

The complete global-hook YAML under Configuration shows this pattern. Normalize the input before comparing it because skill lookup itself is case-insensitive.

Require a section before the run ends

Stop inverts the usual meaning of a rejection: ok: false does not end the run, it sends the agent back to work. maxRejections bounds that retry loop for prompt hooks, but command-hook rejections never increment stop_rejection_count, so a command Stop hook has to bound itself.

#!/usr/bin/env python3
import json
import sys

context = json.load(sys.stdin)
final_output = context.get("final_output") or ""
current_turn = context.get("current_turn")
max_turns = context.get("max_turns")

# Bound the retry on the turn budget, and give up if it is unavailable.
can_retry = (
isinstance(current_turn, int)
and isinstance(max_turns, int)
and max_turns > 0
and current_turn < max_turns - 1
)

if "## Mitigation" in final_output or not can_retry:
print(json.dumps({"ok": True}))
else:
print(json.dumps({
"ok": False,
"reason": "Add a '## Mitigation' section before finishing.",
}))

Security Boundaries

Treat all hook input as untrusted:

  • tool_name, tool_input, tool_result, transcript content, agent names, paths, URLs, branch names, and repository names can be model- or user-driven.
  • Use fixed rejection reasons and fixed context strings. Do not reflect raw payload values into them.
  • Parse JSON with a real parser. Quote shell variables and use argument arrays instead of eval.
  • Default-deny unknown tools, missing required fields, invalid types, and out-of-range values for enforcement hooks.
  • Canonicalize paths and repository identifiers before boundary checks.
  • Do not place credentials in hook scripts, prompts, stdout, reasons, or metadata.

Prompt hooks are model decisions that can be influenced by untrusted context, and malformed prompt output passes. Prefer command hooks for deterministic checks. Use platform permissions and external controls for hard enforcement; neither hook type is a hard isolation boundary.

PageWhat it covers
Agent HooksWhat hooks are, hook levels, and when to use each event
Create and Manage Hooks (Portal)Creating hooks in the portal UI
Configure Agent Hooks (API)Creating hooks with the REST API and YAML
Debug TraceReading the trace panel to find real tool names and arguments
Tool Access PoliciesThe policy layer a permissionDecision of allow bypasses
Custom AgentsDefining the sub-agents a hook can gate
Was this page helpful?