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
HookResultJSON object or nothing at all. To block, reject in that JSON or exit2— on every event exceptStart, which reads onlyadditionalContext. - Prompt hooks receive the same input through
$ARGUMENTSand may return onlyokandreason. - 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
TaskorAgentinputs. 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
| Event | When it runs | Event-specific input | Effective output |
|---|---|---|---|
Start | Once per top-level thread; once per child conversation only for hooks configured directly on that child | start_message, thread_source | Command-hook additionalContext can seed the run. Prompt-hook output has no useful Start effect. |
PreToolUse | Before a tool executes | Tool identity, arguments, mode, annotations, and call_id | Pass, block, ask for confirmation, explicitly allow, or add context. Resumed tools skip this event. |
PostToolUse | After a tool returns | Tool identity, arguments, result, success flag, and call_id | Pass, block the returned result, pause post-execution, or add context. It cannot undo an external side effect that already occurred. |
Stop | When the loop is about to stop | stop_hook_active, stop_rejection_count, final_output | ok: 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
| Key | Type | Availability | Meaning |
|---|---|---|---|
hook_event_name | string | All events | Start, PreToolUse, PostToolUse, or Stop. Use this instead of expecting a context_type key. |
agent_name | string | All events | Agent 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_turn | integer | All events | Current loop turn. Start uses 0. |
max_turns | integer | All events | Turn limit for the run. |
execution_summary | string or null | All events | Path 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 keys | JSON values | When the caller supplies them | Flattened 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
| Key | Type | Meaning |
|---|---|---|
start_message | string or null | Initial message that started the thread. |
thread_source | string or null | Source such as Conversation, Alert, or Incident. Treat it as an open string and handle unknown values. |
PreToolUse keys
| Key | Type | Meaning |
|---|---|---|
tool_name | string | Registered tool name used for matching. |
tool_input | object or null | Arguments selected for this call. Keys are defined by that tool. |
tool_description | string or null | Registered description, when available. |
agent_mode | string | Current mode: Review, Autonomous, or ReadOnly. See Run Modes. |
is_write_action | boolean | True only when the tool carries the WriteAction annotation. It is not a complete mutability guarantee. |
requires_approval | boolean | True when the tool carries RequiresApproval or is in the runtime's approval-required name list. |
requires_browser_connection | boolean | True when the tool carries RequiresBrowserConnection. |
call_id | string | Unique ID for this tool invocation. |
PostToolUse keys
| Key | Type | Meaning |
|---|---|---|
tool_name | string | Registered tool name used for matching. |
tool_input | object or null | Original arguments for the call. |
tool_result | any | Tool 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_succeeded | boolean | Whether 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_id | string | The 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
| Key | Type | Meaning |
|---|---|---|
stop_hook_active | boolean | Whether a prompt Stop hook has already rejected stopping in this run. |
stop_rejection_count | integer | Number of prior prompt-hook rejections. Command-hook rejections do not increment it. |
final_output | string or null | Response 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.
| Tool | Selected input keys | What a hook can infer |
|---|---|---|
ReadFile | filePath, offset, limit | Declared file and requested range |
CreateFile | filePath, content | Declared destination and content |
ReplaceStringInFile | filePath, oldString, newString | Declared edit |
MultiReplaceStringInFile | explanation, replacements | Each replacement contains its own filePath, oldString, and newString |
GrepSearch | query, isRegexp, includePattern, maxResults, includeIgnoredFiles | Declared text search |
FileSearch | query, maxResults | Declared glob search |
ListDir | path | Declared directory listing |
RunInTerminal | command, isBackground | Command string, not the command's eventual file or network effects; this includes Python invoked through the terminal |
Task | subagent_type, prompt, description, optional max_turns | Requested task sub-agent |
Agent | subagent_type, prompt, context, description, name, model, run_in_background | Requested Agent-tool sub-agent |
read_skill_file | skill_name, file_path | Requested skill and relative file; normal activation starts with SKILL.md |
Important current behavior:
Task.max_turnsdefaults to50and currently has no runtime upper bound. Validate it in a PreToolUse hook if users can influence it.Agent.subagent_typeis missing from the tool schema'srequiredlist, but the runtime still requires it.Agentcurrently runs for up to 100 turns.Agent.modelis reserved but ignored, andrun_in_backgroundcurrently executes in the foreground.- The registered directory-listing tool is
ListDir, notListDirectory.
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 code | stdout | Result |
|---|---|---|
0 | HookResult JSON | The JSON decides. |
0 | empty or whitespace | Pass. |
2 | ignored | Blocks. stderr becomes the reason. failMode does not apply. |
| other | ignored | Process 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
}
}
| Key | Type | Meaning |
|---|---|---|
ok | boolean | Pass 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. |
reason | string or null | User-facing rejection reason. A deny reads this top-level field. |
hookSpecificOutput.permissionDecision | string or null | allow, deny, or ask; see event behavior below. |
hookSpecificOutput.permissionDecisionReason | string or null | Preferred question text for ask; it is not the deny reason. |
hookSpecificOutput.additionalContext | string or null | Context added by the hook; see Delivering additionalContext. |
Other keys inside hookSpecificOutput | JSON values | Custom 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.
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)},
)
Recommended command outputs
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: falseand a non-blankreason. - Start uses neither. The runtime reads only
additionalContextfrom a Start hook and discardsokandreason, 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 apermissionDecisionfrom a Start hook:allow,deny, andaskall suppressadditionalContext(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 outcome | additionalContext delivered |
|---|---|
Pass (ok: true, no permission decision) | Yes |
Reject (ok: false) | Yes, alongside the block |
allow | No |
deny | No |
ask | No |
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:
- System-global hooks
- Agent hooks
- 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
additionalContextwins; - 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":
RunInTerminalcan read or modify many files while the hook sees onlytool_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, andrequires_browser_connectionare annotation- or registration-derived hints. Some mutating tools currently reportis_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:
- What is the exact
tool_name? - Which keys are present in
tool_input? - Is the path absolute, sandbox-relative, or repository-relative?
- Which
agent_nameorSubAgentNameappears for this run? - 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:
AgentHookEvaluationis 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.
AgentHookExecutionsuppresses ordinarypassdecisions.FinalDecisionvalues arepass,allow,reject,deny, andask. IndividualDecisionvalues additionally includeerror;passrows are suppressed.Tiervalues aresystem,agent, anduser-global.- The counts include hooks you did not configure. A single user global hook on
PreToolUsecan still reportHooksConfigured: 3because built-in system hooks run alongside it. ReadUserGlobalHooksRunfor your own hooks. RejectionCountcountsrejectdecisions only. Adenyblocks the call but leavesRejectionCountat0.Reasoncarries a command hook's stderr on areject. Adenyreturned throughpermissionDecisionReasondoes not populate it, so readDecisionrather thanReasonto tell whether a call was blocked.ExceptionTypeis 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 anAgentToolExecutionrow; 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:
| Key | Values | Notes |
|---|---|---|
metadata.name | string | Required. Letters, digits, hyphen, underscore; cannot start with a reserved system prefix |
metadata.owner | string | Optional owner |
metadata.tags | string array | Optional tags |
spec.eventType | Start, PreToolUse, PostToolUse, Stop | Required. Event to observe |
spec.activationMode | always, onDemand | Required. On-demand hooks must be activated for the thread |
spec.description | string | Optional human-readable description |
spec.hook.type | command, prompt | Required. |
spec.hook.matcher | regular expression | Matches 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.command | string | Single command. A command hook needs exactly one of command or script |
spec.hook.script | string | Multi-line Bash or Python script, up to 64 KB. Must start with #!/bin/bash or #!/usr/bin/env python3 |
spec.hook.prompt | string | Required for prompt hooks. Place $ARGUMENTS where the input JSON should appear |
spec.hook.model | model scenario or deployment name | Prompt-hook model override |
spec.hook.timeout | integer seconds | Execution timeout; defaults to 30 |
spec.hook.failMode | allow, block | Command-process failure behavior |
spec.hook.maxRejections | integer from 1 to 25 | Prompt 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.
- Event. Confirm the event you configured actually occurs for the action you are testing.
eventType. Values are parsed case-insensitively, sopretooluseandPreToolUseboth resolve. An unrecognized value is rejected on apply. Tool name matching, by contrast, is case-sensitive.- Activation. An
on-demandhook runs only on threads where it has been activated. Open the thread's hook controls and confirm it is active. - Registration. Confirm the hook exists and is enabled with
GET /api/v2/extendedAgent/hooks. - 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.
- An earlier hook short-circuited. A
denyoraskfrom a higher tier stops the chain. QueryAgentHookEvaluationforFinalDecisionandHooksRun. - It ran and passed.
AgentHookExecutionsuppressespassrows, so a chain that ran cleanly leaves no per-hook row. - It ran and failed. Look for
Decision == "error"andExceptionType. A failing process is governed byfailMode, andfailMode: allowmakes 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.
Related
| Page | What it covers |
|---|---|
| Agent Hooks | What 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 Trace | Reading the trace panel to find real tool names and arguments |
| Tool Access Policies | The policy layer a permissionDecision of allow bypasses |
| Custom Agents | Defining the sub-agents a hook can gate |