Hook Declaration Format
Reference for declaring AGH hooks, matchers, subprocess executors, ordering, and runtime execution behavior.
- Audience
- Operators running durable agent work
- Focus
- Hooks guidance shaped for scanability, day-two clarity, and operator context.
A hook declaration binds one event to one executor. The matcher decides whether the hook is eligible; the executor receives the payload and writes a patch. AGH normalizes declarations from config files, agent definitions, skills, and extension manifests into one deterministic pipeline.
Declaration Sources
| Source | Where to declare | Notes |
|---|---|---|
config | [[hooks.declarations]] in AGH config TOML | Global and workspace config declarations are loaded through workspace resolution. |
agent_definition | hooks: YAML or [[hooks]] TOML frontmatter in AGENT.md | AGH scopes the matcher to that agent name. A conflicting matcher.agent_name is rejected. |
skill | metadata.agh.hooks in SKILL.md frontmatter | Marketplace skill hooks are blocked unless allowed by skills.allowed_marketplace_hooks. |
config with extension metadata | [[resources.hooks]] in extension.toml or extension.json | Extension hooks run from the extension root and receive metadata.extension. |
native | Daemon code only | Used by built-in runtime hooks with Go executors. Users should not declare native hooks. |
Field Reference
| Field | Required | Meaning |
|---|---|---|
name | yes | Stable hook name used in catalog and run history. |
event | yes | One event from the event catalog. |
mode | no | async by default; set sync only for sync-eligible events. |
required | no | If true, the hook must be sync; execution failure aborts the dispatch. |
priority | no | Higher values run earlier inside the same source group. |
timeout | no | Go duration such as 5s or 250ms. Subprocess hooks default to 5s. |
matcher | no | Family-specific filters. Empty matcher fields match all values. |
command | subprocess hooks | Executable name or path. Inferred executor kind is subprocess when command is set. |
args | no | Arguments passed directly to the executable. AGH does not implicitly run a shell. |
env | no | Extra environment variables for the hook process. |
executor.kind | no for subprocess command | Explicit executor kind. Use subprocess for user-declared hooks. |
executor.command, executor.args, executor.env | no | Nested executor form. Do not mix nested executor fields with top-level command, args, or env. |
The source and skill_source values in catalog output are assigned by AGH from the declaration
location. They are not fields you normally write.
Execution Model
Rendering diagram...
AGH orders hooks by source, then priority, then name:
- Source order:
native,config,agent_definition,skill. - Higher
priorityruns before lowerpriority. - Skill hooks with the same priority are ordered by skill source:
bundled,marketplace,user,additional,workspace. - Ties sort by hook name.
Default priorities are:
| Source | Default priority |
|---|---|
native | 1000 |
config | 500 |
agent_definition | 100 |
skill | 0 |
Sync hooks run serially. A sync hook sees the payload patched by earlier sync hooks for the same
event. Async hooks are queued only after the sync chain completes without a denial or required-hook
failure. Async execution defaults to 4 workers, queue capacity 64, and 10s shutdown drain
timeout. If the async queue is full, the hook run is recorded as dropped.
AGH limits nested hook dispatch depth to 3. A sync dispatch beyond that depth fails with a
dispatch-depth error. Async hooks beyond the depth limit are recorded as skipped.
Matchers
String matcher fields accept exact values or Go path.Match wildcards: *, ?, and character
classes such as [abc]. Empty strings and * match all values. Invalid wildcard patterns fail
declaration validation. tool_read_only is a boolean exact match.
Each event family accepts only its own matcher fields:
| Family | Allowed matcher fields |
|---|---|
session | agent_name, workspace_id, workspace_root, session_type |
input | agent_name, workspace_id, workspace_root, input_class |
prompt | agent_name, workspace_id, workspace_root, input_class |
event | agent_name, acp_event_type, turn_id |
automation | agent_name, workspace_id |
agent | agent_name, workspace_id, workspace_root |
turn | agent_name, workspace_id, workspace_root, input_class |
tool | tool_name, tool_namespace, tool_read_only |
permission | tool_name, decision_class |
message | message_role, message_delta_type |
context | compaction_reason, compaction_strategy |
coordinator | agent_name, workspace_id, workspace_root, matcher.autonomy.task_id, matcher.autonomy.run_id, matcher.autonomy.workflow_id, matcher.autonomy.coordination_channel_id, matcher.autonomy.coordinator_session_id |
task.run | agent_name, workspace_id, matcher.autonomy.task_id, matcher.autonomy.run_id, matcher.autonomy.workflow_id, matcher.autonomy.coordination_channel_id, matcher.autonomy.release_reason |
spawn | agent_name, workspace_id, workspace_root, matcher.autonomy.task_id, matcher.autonomy.run_id, matcher.autonomy.workflow_id, matcher.autonomy.coordination_channel_id, matcher.autonomy.parent_session_id, matcher.autonomy.root_session_id, matcher.autonomy.child_session_id, matcher.autonomy.spawn_role |
agent_type exists in the internal matcher struct, but no current event family accepts it. Setting
it in a declaration fails validation.
Autonomy correlation fields are nested under matcher.autonomy in config, agent, skill, and
extension declarations. They use the same exact or wildcard string matching as other string
matchers:
[[hooks.declarations]]
name = "block-low-priority-claim"
event = "task.run.pre_claim"
mode = "sync"
required = true
command = "./hooks/claim-policy"
[hooks.declarations.matcher]
workspace_id = "ws_checkout"
[hooks.declarations.matcher.autonomy]
workflow_id = "release-*"
coordination_channel_id = "coord-*"Executors
| Kind | Who should use it | Behavior |
|---|---|---|
subprocess | User, config, agent, skill, and extension hook declarations | Runs a local executable. JSON payload goes to stdin. JSON patch comes from stdout. |
native | Built-in daemon hooks only | Binds a Go executor supplied by the daemon. Rejected for non-native sources. |
wasm | Reserved | The kind exists, but execution currently returns not implemented. |
Subprocess details:
- AGH resolves the command with
exec.LookPath; bare commands usePATH. - The command is not run through a shell unless you explicitly use
sh,bash, or another shell as the command. - Environment is restricted to a safe allowlist plus explicit
enventries. - Stdout and stderr capture is capped at
8 KiB; truncated output ends with...[truncated]. - A timed-out process gets a graceful termination window before AGH kills it.
Complete Example: Config Tool Gate
This config hook inspects write-capable tool calls before they execute. The hook process receives a
ToolPreCallPayload on stdin and must write a ToolCallPatch to stdout.
[[hooks.declarations]]
name = "block-secret-file-edits"
event = "tool.pre_call"
mode = "sync"
required = true
priority = 700
timeout = "3s"
[hooks.declarations.matcher]
tool_namespace = "fs"
tool_read_only = false
[hooks.declarations.executor]
kind = "subprocess"
command = "/usr/bin/env"
args = ["bash", "./hooks/block-secret-file-edits.sh"]
env = { POLICY = "secret-files" }./hooks/block-secret-file-edits.sh:
#!/usr/bin/env bash
set -euo pipefail
payload="$(cat)"
case "$payload" in
*'".env"'*|*'"id_rsa"'*)
printf '{"deny":true,"deny_reason":"Protected secret file path."}\n'
;;
*)
printf '{}\n'
;;
esacComplete Example: Agent Prompt Hook
Agent definition hooks live in AGENT.md frontmatter. AGH automatically scopes them to the agent
name, so this declaration behaves as if matcher.agent_name = "reviewer" were set.
---
name: reviewer
provider: codex
hooks:
- name: add-review-context
event: prompt.post_assemble
mode: sync
timeout: 5s
command: /usr/bin/env
args: ["node", "./hooks/add-review-context.mjs"]
matcher:
input_class: user
---
Review code changes with emphasis on behavior, tests, and operational risk../hooks/add-review-context.mjs:
const chunks = [];
for await (const chunk of process.stdin) {
chunks.push(chunk);
}
const payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
const prompt = payload.prompt ?? "";
process.stdout.write(
JSON.stringify({
prompt: `Review policy: cite file paths and blocking risks first.\n\n${prompt}`,
}) + "\n"
);Complete Example: Extension-Bundled Hook
Extension manifests can package hook declarations with the extension. Relative commands stay inside
the extension root; {{config_dir}} expands to that root and {{env:NAME}} expands from the daemon
process environment.
[extension]
name = "prompt-enhancer"
version = "0.1.0"
description = "Adds workspace context to assembled prompts"
min_agh_version = "0.5.0"
[[resources.hooks]]
name = "workspace-context"
event = "prompt.post_assemble"
mode = "sync"
timeout = "5s"
[resources.hooks.matcher]
input_class = "user"
[resources.hooks.executor]
kind = "subprocess"
command = "node"
args = ["dist/index.js", "hook", "prompt_post_assemble"]
env = { LOG_LEVEL = "info" }The command still follows the hook executor contract: read payload JSON from stdin and write a patch JSON object to stdout. A separate long-running extension subprocess is optional and is documented in Develop Extensions.
HTTP And Agent Workflows
HTTP and agent executors are not implemented hook kinds today. Use one of these patterns instead:
| Need | Current pattern |
|---|---|
| Call an HTTP endpoint from a hook | Use a subprocess hook that runs curl, node, python, or another client. |
| Start or inspect AGH sessions from extension code | Build a subprocess extension and request Host API actions such as sessions/list or sessions/create. |
| Run a long-lived platform integration | Build an extension subprocess; keep hooks as short stdin/stdout commands when they need to patch runtime payloads. |
Example HTTP wrapper:
[[hooks.declarations]]
name = "notify-session-created"
event = "session.post_create"
mode = "async"
command = "/usr/bin/env"
args = ["bash", "-lc", "curl -fsS -X POST \"$WEBHOOK_URL\" -H 'Content-Type: application/json' --data-binary @- >/dev/null && printf '{}\\n'"]
env = { WEBHOOK_URL = "https://hooks.example.test/agh/session-created" }Observability
Hook runs are persisted per session. Each run records hook name, event, source, mode, duration, outcome, dispatch depth, optional patch, error text, required flag, and timestamp.
Possible outcomes:
| Outcome | Meaning |
|---|---|
applied | The hook ran and its patch was accepted. |
denied | The hook returned a denial patch. |
failed | The executor failed or produced an invalid patch. |
skipped | The hook was not run because dispatch depth was exceeded. |
dropped | The async queue was full. |
rejected | A guard rejected the patch, such as a permission escalation attempt. |
Inspect the resolved catalog and execution history through the parallel CLI and tool surfaces:
agh hooks list --workspace checkout --event tool.pre_call
agh hooks info block-secret-file-edits --workspace checkout
agh hooks runs --session sess_9f4a --event tool.pre_call --last 20 -o jsonAgents can read the same projection through agh__hooks_list, agh__hooks_info,
agh__hooks_events, and agh__hooks_runs. Agents may also mutate config-backed or
overlay-backed declarations through agh__hooks_create, agh__hooks_update,
agh__hooks_delete, agh__hooks_enable, and agh__hooks_disable. Source-owned hooks remain
structurally immutable through agent tools; the daemon returns
HOOK_SOURCE_IMMUTABLE instead of attempting the mutation.
Related references: