Hook Declaration Format
Reference for declaring CompozyOS hooks, matchers, subprocess executors, ordering, and runtime execution behavior.
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. CompozyOS 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 CompozyOS config TOML | Global and workspace config declarations are loaded through workspace resolution. |
agent_definition | hooks: YAML or [[hooks]] TOML frontmatter in AGENT.md | CompozyOS scopes the matcher to that agent name. A conflicting matcher.agent_name is rejected. |
skill | metadata.compozy.hooks in SKILL.md frontmatter | Marketplace skill hooks are blocked unless allowed by skills.allowed_marketplace_hooks. |
extension | [[resources.hooks]] in extension.toml or extension.json | Extension hooks run from the extension root and receive metadata.extension. Generated from the SDK's supported hook events for subprocess extensions. |
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. CompozyOS 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 CompozyOS from the declaration
location. They are not fields you normally write.
Execution Model
Rendering diagram…
CompozyOS orders hooks by source, then priority, then name:
- Source order:
native,config,extension,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 |
extension | 300 |
agent_definition | 100 |
skill | 0 |
Source order is the outer sort key, so an extension hook always runs after every config hook and
before every agent-definition hook regardless of the priority each one declares.
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.
CompozyOS 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 | agent_name, workspace_id, workspace_root, worktree_id, tool_id, tool_read_only |
permission | tool_name, decision_class |
message | message_role, message_delta_type |
context | compaction_trigger |
coordinator | agent_name, workspace_id, workspace_root, matcher.autonomy.task_id, matcher.autonomy.run_id, matcher.autonomy.workflow_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.release_reason |
spawn | agent_name, workspace_id, workspace_root, matcher.autonomy.task_id, matcher.autonomy.run_id, matcher.autonomy.workflow_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.
compaction_trigger accepts requested or agent and narrows a context.pre_compact or
context.post_compact hook to compactions CompozyOS asked for (Compact now, compozy session compact,
the compozy__session_compact tool, or a Goal) or to compactions the agent started on its own. An
empty value or * matches both. Like every string matcher it is validated only as a wildcard
pattern, so a value other than requested or agent is accepted and never matches. The matcher
applies only to the two context events; on any other event the declaration fails validation.
compaction_trigger replaces the earlier compaction_reason and compaction_strategy matchers.
No source rejects a declaration for carrying them. How each source handles them depends on where the
declaration lives:
| Source | Handling |
|---|---|
config ([[hooks.declarations]] in a config.toml) | The two keys are removed from the matcher and archived at the end of the same file on load. See Archived retired settings. |
extension ([[resources.hooks]] in extension.toml/.json) | The two keys are ignored and CompozyOS logs one extension.retired_entries_ignored warning per manifest load, listing entries such as hooks[0].compaction_reason. |
agent_definition (hooks in AGENT.md) | The two keys are ignored and CompozyOS logs one agent.retired_entries_ignored warning per load, with the agent name and the ignored entries, such as hooks[0].matcher.compaction_reason. AGENT.md is not rewritten. |
skill (metadata.compozy.hooks in SKILL.md) | The two keys are ignored and CompozyOS logs one skills.retired_entries_ignored warning per skill, with its path, name, and the ignored entries, such as hooks[0].matcher.compaction_reason. SKILL.md is not rewritten. |
In every case the declaration keeps running with no trigger filter, so it observes every compaction,
whatever its trigger. The rest of the matcher is still validated: an unknown or malformed matcher
field next to the retired keys is still rejected. The AGENT.md and SKILL.md handling is a
compatibility shim that CompozyOS removes in v0.6.0, so replace the two keys with
compaction_trigger before then.
An AGENT.md hook cannot target the context events at all. CompozyOS adds the agent's name as
matcher.agent_name on every agent-definition hook, and the context family does not accept
agent_name, so the file fails with
agent.hooks[0]: hooks: matcher fields [agent_name] are not valid for event "context.pre_compact".
Declare context hooks in config.toml, an extension manifest, or a skill.
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-*"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:
- CompozyOS 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 CompozyOS 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_id = "*"
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. CompozyOS 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_compozy_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.
Tool-event matchers use the canonical tool_id, such as compozy__workspace_info, or an ID pattern.
tool_name belongs to permission-event matchers. tool_namespace is not a supported matcher field.
HTTP and agent runtimes
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 CompozyOS 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/compozy/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:
compozy hooks list --workspace checkout --event tool.pre_call
compozy hooks info block-secret-file-edits --workspace checkout
compozy hooks runs --workspace checkout --session sess_9f4a --event tool.pre_call --last 20 -o jsonAgents can read the same projection through compozy__hooks_list, compozy__hooks_info,
compozy__hooks_events, and compozy__hooks_runs. Agents may also mutate config-backed or
overlay-backed declarations through compozy__hooks_create, compozy__hooks_update,
compozy__hooks_delete, compozy__hooks_enable, and compozy__hooks_disable. Source-owned hooks remain
structurally immutable through agent tools; the daemon returns
HOOK_SOURCE_IMMUTABLE instead of attempting the mutation.
Related references: