| type | concept | ||
|---|---|---|---|
| topic | runtime | ||
| audience |
|
Hooks are a thin operator-scoped layer that lets you inject context, trigger self-continuation, run a sandboxed side-effect, or launch a pipeline at any of the four lifecycle points in a reyn session — or, at an external-event point fired by something outside the session's own run-loop (a subscribed MCP resource changing, a watched file changing, a cron job firing, or an inbound webhook).
They are built on two mechanisms that already exist: the unified inbox (the channel that feeds messages into a turn) and the P6 lifecycle (the event stream). No new OS machinery — a new workflow that uses hooks does not require any OS change (P7).
Everything below is detailed further on; this section is a self-contained
lookup for authoring a hooks: (and, if needed, composers:) block without
reading the rest of the page.
These are two different, non-interchangeable vocabularies — a value valid in one is not necessarily valid in the other:
| Bare form | Namespaced form (also accepted) | Fires | Valid as a hook's on:? |
Valid as a Composer inputs[].kind? |
|---|---|---|---|---|
session_start |
builtin:lifecycle:session_start |
session opens | ✅ | ✅ |
session_end |
builtin:lifecycle:session_end |
session closes | ✅ | ✅ |
turn_start |
builtin:lifecycle:turn_start |
a turn begins | ✅ | ✅ |
turn_end |
builtin:lifecycle:turn_end |
a turn's terminal stop_reason |
✅ | ✅ |
mcp_resource_updated |
builtin:external:mcp_resource_updated |
a subscribed MCP resource pushes an update | ✅ | ✅ |
file_changed |
builtin:external:file_changed |
a watched path changes (fs_watch required) |
✅ | ✅ |
cron_fired |
builtin:external:cron_fired |
a message-based cron: job delivers |
✅ | ✅ |
webhook_received |
builtin:external:webhook_received |
an inbound webhook resolves to this session | ✅ | ✅ |
| — (open) | composed:<name> |
a Composer (see below) publishes its correlated output | ✅ | ✅ (chaining — another Composer's output) |
| — (open) | llm:<session_id>:<event_name> |
the LLM itself emits one via emit_hook_event (always its own session) |
❌ rejected at load (HookConfigError) |
✅ |
llm:* can never be a hook's on: value — only a Composer input. A
hooks: entry only accepts the 8 builtin bare/namespaced forms above or a
composed:<name> prefix; anything else (including a well-formed
llm:<session_id>:<event_name>) is a load-time HookConfigError. To react
to an LLM-emitted event, correlate it through a composers: entry into a
composed:<name> event, then put your hooks: entry's on: on THAT
composed kind — see the worked example
below. The bare/namespaced-form duality only applies within the 8 builtin
points — it does not extend on:'s acceptance to llm:*.
A hook entry sets exactly one scheme. Every scheme accepts the two
top-level fields on (required, see above) and name (optional string,
defaults to the on value — becomes the [hook:<name>] attribution prefix)
plus the optional matcher (see below).
template_push — a Jinja2-templated inbox push:
| Field | Type | Default | Meaning |
|---|---|---|---|
message |
str (Jinja2) | required | Rendered text of the pushed [hook:name] message. |
wake |
bool | str (Jinja2 → bool) | true |
true starts a new turn (self-continuation, E); false rides passively into the next turn (context-inject, C). |
push_when |
str (Jinja2 → bool) | "true" |
false skips the push entirely (conditional push). |
session |
str | None | None (current session) |
Routes the push to a different session's inbox — cross-session push. |
exec — a sandboxed side-effect argv (renamed from shell_exec in #3226
Phase 4 — naming honesty, not a security change: this scheme never ran
/bin/sh -c <string>; it always executed a tokenized argv with shell=False.
The rename removes the misleading shell_ prefix and — a clean break, not a
compat alias — collapses the payload to argv-list-only):
| Field | Type | Default | Meaning |
|---|---|---|---|
exec |
list[str] (static — not Jinja2) |
required | The argv. Non-empty list of non-empty strings. stdout/stderr are ignored; the event is written to the process's stdin as JSON. |
exec is deliberately NOT templated. Unlike message/input_template
above, the argv is run as-is, one process-arg per list item — event/context
data is never interpolated into it. This is an intentional command-injection
guard, not an oversight: if you need the firing event's data inside the
command, read it from stdin, which always carries the event's payload as
JSON (e.g. a composed event's {"inputs": [...], "correlation_key": ...}) —
the command must read and parse stdin itself, never expect {{ ... }}
substitution in its own argv.
Migration from the pre-Phase-4 shell-command string: shell_exec: "scripts/cleanup.sh --force"
becomes exec: ["scripts/cleanup.sh", "--force"] — split the command line into
one argv entry per token yourself (the runtime no longer does this via
shlex.split).
exec_capture — a sandboxed argv whose stdout IS a push directive
(renamed from shell_push in #3226 Phase 4 — same naming-honesty rationale
and argv-list-only payload as exec above):
| Field | Type | Default | Meaning |
|---|---|---|---|
exec_capture |
list[str] |
required | The argv. Non-empty list of non-empty strings. stdout must be pure JSON: {"push_when": bool, "wake": bool, "message": str, "session"?: str} (first three required). Any failure (non-zero exit, invalid JSON, missing/wrong-typed field) skips the push, fail-safe. |
pipeline_launch — launch a registered pipeline, async/detached:
| Field | Type | Default | Meaning |
|---|---|---|---|
name |
str | required | The pipeline's registered name, resolved at dispatch time. Unregistered → warning + skip, lifecycle point still completes. |
input_template |
dict | str | None | None |
dict: every string leaf (recursively) is Jinja2-rendered. str: rendered once, output parsed as a JSON object. None: launches with no input. |
matcher: {field: pattern, ...} — evaluated against the firing event's
template vars before the hook's action runs.
- Match rule by field name:
uriandpathuse a shell-style glob (fnmatch); every other field name is exact string equality. - Absent or empty
matcher→ the hook always fires. - For the 8 builtin points, a matcher field outside that point's payload
(a typo, or a field the point never carries) is a load-time
HookConfigError— rejected before the hook can ever run. - For a hook's
matcheron acomposed:*on:target, or a Composer input'smatchon anllm:*inputs[].kind(neither has a builtin schema entry — the open set), a field the event doesn't carry never matches at runtime (nothing to validate against at load time).
A Composer correlates multiple Bus events into one derived composed:<name>
event, independent of the hooks: block (see
Async Bus and Composer below
for the full model):
composers:
- name: deploy_approved # required, unique
op: all # required: all | any | seq | window | debounce | correlate_by | count
inputs: # required, non-empty list
- kind: builtin:external:mcp_resource_updated # required
match: {server: "github"} # optional — same field->pattern grammar as `matcher`
- kind: mcp:approval-server:approved
emit:
kind: composed:deploy_approved # required, MUST start with `composed:`
policy: # optional
capacity: 10 # int, default 10
overflow: reject # drop_oldest (default) | drop_newest | reject
ttl: 5m # duration: plain seconds, or "<N>s"/"<N>m"/"<N>h" — default 5m
correlate_by: request_id # required IFF op: correlate_by — the payload field to key on
count: 3 # required IFF op: count — the thresholdinputs[].kindnames a builtin namespaced kind (builtin:lifecycle:*/builtin:external:*), anllm:<session_id>:<event_name>kind an LLM emits viaemit_hook_eventin that same session, acomposed:*kind from another Composer (chaining — cycles are rejected at load time), or an external-plugin kind (mcp:<server>:<event>, etc.). The LLM can only ever produce anllm:*event (viaemit_hook_event); it cannot author acomposers:block itself (that's an operator-owned config), only supply the events one correlates on.inputs[].source, if present, must be omitted or"builtin"— anything else is a load-time error (the Bus only ever carriessource="builtin"; correlate on a payload field instead).- The 8
opvalues:all(every input arrived),any(first arrival, stateless),seq(inputs' kinds arrive in the declared order),window(fire afterttlfrom the FIRST match, with everything buffered),debounce(firettlafter the LAST match with no newer one),correlate_by(likeall, keyed by a payload field),count(N matching events per key),deadline(issue #3166 — fire when itsuntilpattern does NOT arrive withinttlof itsonpattern, per key; see reyn-yaml §composersblock for its distincton/matcher/untilshape and its stricter — crash-durable by default — reliability posture). Unlike the other 7 ops, which all compose things that DID happen,deadlineis the only one that fires on absence — read adeadlinefire as "X was expected but never arrived", never as an error in itself. inputsarity:seqrequires at least 2 inputs (a load-time error otherwise) — every other op accepts a single input, includingall/any(both fire immediately on that one input's first arrival) andcount(fires oncecountmatching events of that one kind have arrived). A single-input Composer is the common shape for reacting to just onellm:*signal:composers: - name: deploy_ready op: any # any/all are equivalent with 1 input inputs: - kind: llm:main:deploy_ready emit: { kind: composed:deploy_ready }
- What the composed event carries.
_emit_composedbuilds the emitted event's payload as{"inputs": [<matched event's payload dict>, ...], "correlation_key": <key or "__default__">}— so a downstream hook's template vars are{{ inputs }}(a list) and{{ correlation_key }}, NOT the original event's fields flattened at the top level. Order caveat:inputs[]is in ARRIVAL order, not declared-config order, for every op exceptseq(whose whole point is enforcing the declared order) — and each entry is the bare payload dict with nokindtag to identify which declared input it came from. So{{ inputs[0].<field> }}is only reliably "the first declared input's field" when the Composer usesop: seq, or has exactly one input (nothing else can arrive first). - A
composed:<name>event becomes a normal Syncon:target — add ahooks:entry withon: composed:deploy_approvedto react to it (see the worked example below).
The LLM's own tool for putting an event onto its session's Bus — see LLM-authored hook-events below for the full syntax, autonomy boundary, and a worked example.
Hooks fire at four lifecycle points, one for each combination of scope and direction:
| Scope | _start |
_end |
|---|---|---|
| session | session_start |
session_end |
| turn | turn_start |
turn_end |
Every point is an awaited dispatch: the hook completes (shell exits, push is queued) before the lifecycle point continues. This is what gives shell hooks synchronous access to the moment — a session_start shell hook finishes before the first turn begins.
Implementation anchors:
turn_endfires at the terminalstop_reason
Unlike the four lifecycle points above — fired from the session's own turn run-loop — an external-event point is fired by something outside that loop: today, a subscribed MCP resource changing.
Fires when a server pushes a resources/updated notification for a resource
this session subscribed to via subscribe_mcp_resource (see
Resource subscriptions).
Delivered from the MCP receive-loop task through a bounded queue drained on
the session's own event loop — not from the agent's own turn/task machinery
— so it can fire between turns, not only at a turn/task boundary.
Template vars available to template_push / pipeline_launch rendering:
| Var | Meaning |
|---|---|
server |
The MCP server name the resource belongs to. |
uri |
The updated resource's URI. |
resync |
true if this firing is a reconnect resync (see below), false for a real server push. |
Resync on reconnect. Reyn keeps no resource-content cache, so a dropped
connection could silently miss updates that happened while disconnected.
After a transport-death reconnect re-establishes every previously-tracked
subscription, this hook-point fires once per re-subscribed URI with
resync: true — a conservative "this may have changed while you were
disconnected, re-read if you care" signal, using the exact same hook-point
and template-var shape as a real push. It never fires on a session's very
first connection (there is nothing to resync).
Fires when a file under an operator-declared watch path is created, modified,
or deleted. Requires the watchdog extra (pip install reyn[fs-watch]) and
at least one path under fs_watch.paths in reyn.yaml:
fs_watch:
paths:
- /repo/src
- /repo/docs
debounce_seconds: 0.2 # coalesce a write-burst on one path into ONE fireFull field reference: reyn-yaml § fs_watch block.
Without either the extra or a configured path, the feature is off (a clear
warning is logged once if paths are configured but the extra is missing; no
config at all is silently byte-identical to a build with no watcher).
Template vars:
| Var | Meaning |
|---|---|
path |
The changed file's path. |
event_type |
created, modified, or deleted. |
Watched paths are declared once, at startup, in the OUT-set (reyn.yaml /
reyn.local.yaml) — there is no op or tool verb that lets an agent register
or widen a watch; a filesystem-wide change feed is treated as the same class
of concern as sandbox policy. Bursts of events for one logical change (an
editor's temp-file dance, a create-then-modify) are debounced per path — one
burst fires the hook once, not once per underlying filesystem event.
Fires when a message-based cron: job delivers to its own session.
Template vars:
| Var | Meaning |
|---|---|
job_name |
The fired job's configured name. |
to |
The target agent name. |
Fires when an inbound webhook (Slack, LINE, a generic plugin) resolves to a session.
Template vars:
| Var | Meaning |
|---|---|
transport |
The logical transport (slack, line, webhook, ...). |
sender |
The full routing sender string ("<transport>:<external_id>"). |
The template context deliberately carries only this routing metadata —
never the raw inbound request body, which may carry tokens or PII the
operator never intended a hook action to see. Contrast cron_fired's
job_name/to, which are operator-authored config, never end-user-supplied.
Both cron_fired and webhook_received are non-blocking relative to their
ingress: the cron job's own inbox delivery and the webhook's HTTP response
never wait on a hook action — dispatch is scheduled as a fire-and-forget
background task, so a slow hook (e.g. a multi-second exec) can never
stall the ingress that triggered it.
A hook may set matcher, a dict[str, str] of field → pattern, evaluated
against the firing event's template vars before the hook's action runs:
hooks:
- on: mcp_resource_updated
matcher: {server: "github", uri: "file:///repo/**"}
template_push:
message: "{{ uri }} changed on {{ server }}."- Every named field must match: exact string equality, except
uriandpath, which match via a shell-style glob (fnmatch) — sofile:///repo/**matches any URI under that prefix, and/repo/src/**matches any watched path under that directory. - For the 10 builtin hook points (6 lifecycle +
mcp_resource_updated/file_changed/cron_fired/webhook_received), a matcher field must be one the point's builtin schema actually carries — a typo'd or nonexistent field name (e.g. a lifecycle point's matcher namingserver/uri, orpayload.srever) is a load-timeHookConfigError, rejected before the hook can ever run (a schema-external matcher would otherwise never fire — fail-loud replaces that silent footgun). - For a future or custom point with no builtin schema entry (the schema-driven open set), a field the firing event doesn't carry still never matches at runtime — the pre-schema behavior, since there is nothing to validate against at load time.
- Absent or empty matcher → the hook always fires — the default, and the
behavior every pre-
matcherhook keeps unchanged.
The rule is keyed off the field name (uri/path glob, everything else is
exact), not the hook-point — so a future external-event source that also
emits a uri- or path-shaped field gets glob matching for free.
Each entry carries exactly one of four mutually-exclusive schemes:
template_push— a push directive built from config Jinja2 templates.exec— a sandboxed argv run as a pure side-effect (output ignored). Renamed fromshell_execin #3226 Phase 4 (naming honesty, argv-list-only payload — see above).exec_capture— a sandboxed argv whose stdout is a JSON push-directive, pushed via the same path astemplate_push(the only difference is the directive's source: captured stdout vs a Jinja2 render). Renamed fromshell_pushin #3226 Phase 4.pipeline_launch— launch a registered pipeline with input rendered from the event's template vars. See Pipeline launch below.
Those schemes deliver four behavioral capabilities, uniformly:
A passive [hook:name] system message is queued into the unified inbox. It
rides along with the next turn — no extra turn is triggered. Use it to
append read-only context (metrics, timestamps, retrieved facts) that the LLM
sees in the conversation without being asked to act on it immediately. Produced
by a template_push or an exec_capture whose directive sets wake: false.
Same as C, but the wake: true flag signals the run-loop to open a new turn
immediately. This is the differentiating capability: a turn_end hook can
restart the agent without any human input. Bounded by the loop valve.
Produced by a template_push or an exec_capture with wake: true.
A sandboxed command is executed. Reyn writes a JSON event to the command's stdin; its stdout and stderr are ignored. Use it to update external state — write a log entry, emit a metric, post to a webhook. See Sandbox for the safety model.
A sandboxed command whose stdout is a single JSON object
{"push_when": bool, "wake": bool, "message": str, "session"?: str} (first
three required). stdout is parsed into the same push directive a template_push
produces, then dispatched via the identical C/E path — so the command decides
at runtime whether to push (push_when), how (wake), and what (message).
stdout must be pure JSON (logs go to stderr). Any failure — non-zero exit,
invalid JSON, or a missing / wrong-typed field — skips the push (fail-safe);
the lifecycle point always proceeds. session names the target session for
cross-session push (see below) — omitted, it defaults to the current
session.
Launches a registered pipeline by name, with an input
built from the firing event's template vars:
hooks:
- on: mcp_resource_updated
matcher: {uri: "file:///repo/docs/**"}
pipeline_launch:
name: reindex_docs
input_template: {uri: "{{ uri }}"}name— the pipeline's registered name, resolved at dispatch time. If it isn't registered, the hook logs a warning and skips the launch — the lifecycle/external-event point still completes normally, exactly like any other hook failure.input_template— optional. Adict's string leaves (recursively) are each Jinja2-rendered against the template vars; a plain string is rendered once and its output parsed as a JSON object (mirroringexec_capture's "stdout is JSON" contract); omitted, the pipeline launches with no input.- Async/detached, works from any hook-point (lifecycle or
mcp_resource_updated): the launch is the samerun_pipeline_asyncpath — the hook fires-and-continues, the pipeline runs in its own crash-recoverable driver-session, and the result arrives later on this session's own inbox as apipeline_resultmessage.
A template_push or exec_capture directive's session field routes the
push to a different session's inbox instead of the current one — the
target session processes it exactly as it would its own hook push (wake
rides along: true triggers a turn there, false rides passively into its
next turn). Naming the current session, omitting session entirely, or
running in a context with no cross-session routing capability all fall back
to the local (current-session) push.
wake (default true) is what splits C from E. The run-loop drains the inbox
after each turn:
- Collect all queued hook messages.
- Any
wake: falsemessages are included as context in the upcoming turn (or held for the next human-driven turn if nowake: trueis present). - The loop fires one new turn if at least one
wake: trueis present — allwake: falsemessages from the same batch ride along as context in that same turn.
If no hooks are configured or none match the current lifecycle point, the loop is byte-identical to a hooks-free session. Zero overhead on the happy path.
Pushes are new attributed [hook:name] system messages added to the
conversation. They do not mutate existing history — object identity is preserved
on every existing message. This is tested at the object-identity level, not just
content equality.
Shell output is intentionally ignored. Reyn does not support transform-hooks (hooks that rewrite the context or the artifact stream). Real redaction, truncation, and content fencing stay at the OS layer where they are visible, evented, and auditable (see secret-handling and content-layer defense).
Hooks are dispatched by HookDispatcher, a first-class synchronous awaited call
at each lifecycle point. This is not an EventLog subscriber:
| Mechanism | Timing | Use |
|---|---|---|
HookDispatcher |
awaited first-class | hooks — must complete before the lifecycle point continues |
| EventLog subscriber | sync-inline, no await | real-time console render, analytics |
| WAL | append-only durable log | crash recovery |
| P6 audit event | async-tolerant | audit trail, replay, eval |
Subscribers are sync-inline and cannot await — they are fire-and-forget at
emit time. A shell hook that needs to wait for a process to exit cannot be
implemented as a subscriber. HookDispatcher solves this.
Each hook is wrapped in its own try/except block. A hook failure is logged and
attributed to the hook by name; it does not abort the lifecycle point or
propagate to the LLM output.
E (self-continuation) is bounded to prevent runaway hook-driven sessions:
- Counter:
safety.loop.max_hook_driven_turns(default25) counts hook-driven turns since the last human user turn. - Reset: the counter resets to zero on every human turn.
- On cap: the configured
safety.on_limitaction fires —warn→ask_user→abort. All three leave the session alive (no silent kill). - Unlimited: set
max_hook_driven_turns: 0to disable the cap entirely.
The valve is a backstop, not an obstruction. A well-designed self-continuation hook will finish before the cap; the cap catches runaway loops that a bug or unexpected workflow behavior would otherwise leave open.
exec/exec_capture hooks run inside the same backend-agnostic sandbox abstraction as
Control IR sandboxed_exec ops: Seatbelt (macOS), Landlock/seccomp (Linux), Noop
(unsupported platforms), or a container backend.
A hook shell's sandbox is scoped per hook. Three axes start at a floor and
only an explicit key on that hook moves them
(hooks: block):
| Axis | Floor | The operator's key |
|---|---|---|
| fork | no subprocess spawning | subprocess: true |
| network | outbound network blocked | network: true |
| writes | no writable paths | write_paths: [...] |
Omitting a key keeps that floor, so a hook that declares nothing is as bounded
as it has always been. Each axis is deliberately per-hook rather than global:
what a hook needs from the sandbox is a property of the operator's own command.
A git/npm hook forks; a pure-python one does not — so there is no safe
blanket default (contrast a stdio MCP server's subprocess:, which defaults
on because such a server forks to exist). A command that forks internally — a
bare command resolving to a pyenv/asdf/mise shim, or an npx/uvx
launcher — is denied under the floor and logged with denial_class=fork_denied,
naming it an environment/config problem rather than a command failure.
Two boundaries hold regardless of the keys:
- The sensitive-file read deny-list (
~/.ssh,~/.aws, …) applies toexec/exec_capturehooks as it does to any other sandboxed run, and awrite_pathsgrant does not pierce it — the deny wins over an overlapping grant. - Consent is fail-closed: if the sandbox backend cannot be confirmed, the hook is refused rather than run unsandboxed.
The agent-level sandbox.policy
governs sandboxed ops and the OS's in-process file/http gates. It does not
reach a hook shell. That is a structural choice, not an oversight: a hook is a
small declarative reaction to a lifecycle event, so its floor should not move
because a run's ops are deliberately unsandboxed. The hook site is where a
hook's sandbox is decided.
What the OS does not do is ignore you. If the agent-level policy declares one of
the three axes above and an exec/exec_capture hook does not re-declare it, the run logs a
WARNING naming the per-hook key that reaches it and emits a
sandbox_policy_not_applied audit-event recording what was configured, what
actually applied, and which hook. Declaring the key on the hook — to any
value, including the floor — is your decision on that axis, and a decision is
never reported. The invariant: an operator's expressed will is applied or
refused, never silently dropped.
exec/exec_capture argv require operator consent before they run. The consent flow depends on whether a live intervention listener is attached:
- Interactive chat session (inline CUI) — consent routes through the unified intervention bus and renders as a closed-set intervention in the above-input region: "Shell hook
<name>wants to run a command" (the hook's configuredname:field, or a generic message if unnamed; the prompt text still says "shell hook" — a UI-label detail, not a config field name). Three choices:- [A]lways — allow and persist to the allowlist (
~/.reyn/shell-hooks-allowlist.json, override viaREYN_SHELL_HOOKS_ALLOWLIST). Future runs of the same command are auto-approved. - [y]es — allow this run only.
- [n]o — skip (fail-closed).
- [A]lways — allow and persist to the allowlist (
- Non-interactive (
reyn run,mcp-serve, headless) — falls back to the pre-bus behavior: TTY stdin prompt when available, or refused when stdin is not a TTY. - Allowlist hit — any command already in the allowlist runs silently without a prompt (auto-approved on all surfaces).
Consent is fail-closed throughout: if the sandbox backend cannot be confirmed, the hook is refused rather than run unsandboxed.
has_active_listener is re-checked on every dispatch, not cached (#2095). Listeners attach and detach over a session's lifetime — a TUI mount, an A2A request window opening and closing — independently of when the HookDispatcher was constructed. Checking the listener state once at construction and caching the routing decision would let a later dispatch route a consent prompt to a listener that has since detached (a hang, or a silently dropped prompt) instead of correctly falling through to the non-interactive path (REYN_ACCEPT_HOOKS / fail-closed, or the reyn run stdin prompt on a TTY). The consent-bus wiring at this call site is byte-identical to the pre-#2095 fallback matrix in every case EXCEPT that the routing decision (bus vs None) is now made per-dispatch against live listener state rather than frozen at construction — the per-dispatch check itself lives in the dispatcher, not at this construction site.
See sandbox for the full backend model and permission model for the broader consent architecture.
Every exec/exec_capture hook run — including silently auto-approved runs — emits a hook_shell_executed P6 event (under the "tool" group; the event kind name itself is unchanged by #3226 Phase 4 — only the mode value below was renamed), recording:
exec: <command> [rc=N]
(exec_capture: prefix for push-mode hooks — renamed from shell_exec:/shell_push: in #3226 Phase 4.) The return code suffix is omitted when the command exits 0. This gives the operator a complete audit trail of exec-hook activity regardless of consent path.
Hooks are declared under the hooks: key in reyn.yaml. See the
reyn-yaml reference § hooks block
for the full schema.
Brief example — a turn_end self-continuation template_push, a session_start
exec, a turn_end exec_capture whose stdout decides the push, and a
matcher-narrowed mcp_resource_updated pipeline_launch:
hooks:
- on: turn_end
template_push:
message: "Run complete. Check for pending tasks."
wake: true
- on: session_start
exec: ["touch", "/tmp/reyn-session-started"] # argv only — no shell redirection (">>")
- name: dynamic
on: turn_end
exec_capture: ["scripts/decide-next.sh"] # emits {"push_when":true,"wake":true,"message":"..."}
- on: mcp_resource_updated
matcher: {server: "github", uri: "file:///repo/docs/**"}
pipeline_launch:
name: reindex_docs
input_template: {uri: "{{ uri }}"}The wake: true on the first hook triggers a new turn after each turn_end,
with the message injected as the system context. The exec on
session_start runs its argv as a pure side-effect; its output is discarded
(argv is executed directly — no shell, so a literal >> redirect token would
NOT redirect; use a script or an explicit ["sh", "-c", "..."] argv if you
need shell semantics). The exec_capture
runs its argv, parses stdout, and pushes only if the directive says so.
The last hook only fires for a github-server resource under
docs/ — and, when it does, launches the reindex_docs pipeline
asynchronously with the changed URI as input.
Everything above is the Sync path: an awaited, per-hook dispatch at each lifecycle/external point. reyn also has a per-Session Async Bus — independent pub/sub broadcast of the same events, with no consume semantics (every subscriber observes the same broadcast simultaneously) — and, built on top of it, a Composer that correlates multiple events into one derived "composed" event.
Scope is per-Session by construction (Hook-Event Redesign Phase 4a, proposal 0059 §3.2/§3.3). Each Session constructs its own HookBus instance, feeding its own HookDispatcher; a Bus is never shared across Sessions. This is a deliberate v1 scope limit, not an oversight — v1 has no cross-session event observation or correlation. Concretely, HookBus.publish delivers to every subscriber attached to that instance; two Sessions sharing one Bus would let a subscriber on one Session observe events published from the other. No subscriber attaches to a fresh Bus until something explicitly calls session._hook_bus.subscribe() — the Composer (Phase 4b) is the first consumer that does; until then publish() hits its zero-subscriber path and is a no-op.
A Composer watches the Bus, buffers matching events per its configured
op, and — once the op's condition is met — publishes ONE new event with
kind = "composed:<name>" back to the same Bus. Eight ops:
| Op | Fires when |
|---|---|
all |
every one of N distinct inputs has arrived (per correlation key) |
any |
the first matching input arrives (stateless) |
seq |
the inputs' kinds arrive in the CONFIGURED order (an out-of-order arrival resets progress) |
window |
ttl seconds have elapsed since the FIRST matching event — fires with everything buffered |
debounce |
ttl seconds have elapsed since the LAST matching event with no newer one in between |
correlate_by |
like all, but keyed by a payload field (e.g. a request id) instead of one global bucket |
count |
threshold matching events have arrived (per key) |
deadline |
its until pattern has NOT arrived within ttl of its on pattern arming (per key) — issue #3166, the only op that fires on absence, not occurrence |
composers:
- name: deploy_approved
op: all
inputs:
- { kind: builtin:external:mcp_resource_updated, match: { server: "github" } }
- { kind: mcp:approval-server:approved }
policy: { capacity: 10, overflow: reject, ttl: 5m }
emit: { kind: composed:deploy_approved }Correlate on payload, never on source. Every event on the Bus carries
source="builtin" — kind already encodes the source TYPE
(mcp_resource_updated vs file_changed vs ...) and payload already
carries the source INSTANCE (payload.server / payload.path /
payload.job_name / payload.transport). A Composer input naming a
source other than "builtin" can never match anything the Bus carries and
is rejected at config-load time (the same typo-resistance posture the
matcher's schema validation already has for payload fields).
Reliability posture — best-effort, not a recovery feature. A Composer's
in-flight correlation state is held in memory only and is lost on a process
crash (a partially-matched all/seq/correlate_by simply never fires).
This is a deliberate v1 scope decision, not an oversight: the Bus itself is
already lossy under backpressure (a slow subscriber drops the oldest
unread event), so a Composer built on top of it cannot promise more
reliability than its input. Overflow of a Composer's own pending state
follows one of three policies — drop_oldest / drop_newest / reject
(no publisher-blocking backpressure) — and every drop, whether from
overflow or a ttl-aged incomplete correlation, is surfaced as a
composer_dropped P6 event (metadata only: composer name + correlation key
- reason, never the buffered payload content) so a composition that quietly
never fires is never silent. A successful fire is
composer_fired(same metadata-only shape).
Composed events reach Sync via a dedicated bridge, never HookDispatcher. dispatch() itself. A composed:<name> event is published ONLY to the
Bus by the Composer (invariant #5 above stays true — a Composer never calls
HookDispatcher.dispatch()/hooks_for() directly). Hook-Event Redesign
Phase 5 part 1 (#2881) opened
composed:<name> as a subscribable Sync on: target — a
reyn.hooks.composed_consumer.ComposedEventConsumer subscribes to the same
session HookBus and, for every observed composed:* event, runs any
Sync-registered hook whose on: names that kind
(HookDispatcher.dispatch_bus_event) — WITHOUT re-publishing to the bus
(re-broadcasting an already-bus-delivered event would double-deliver it to
any sibling Composer correlating on the same kind). A composer config that
would create a composition cycle (composer A depends on composer B's output
which depends on A's) is still rejected at config-load time, never
discovered at runtime — that DAG check is independent of, and unaffected
by, this Sync consumer.
hooks:
- on: composed:deploy_approved # a composed event as a Sync on: target
exec: ["reyn", "deploy.sh"]
composers:
- name: deploy_approved
op: all
inputs:
- { kind: builtin:external:mcp_resource_updated, match: { server: "github" } }
- { kind: mcp:approval-server:approved }
policy: { capacity: 10, overflow: reject, ttl: 5m }
emit: { kind: composed:deploy_approved }A Session reads composers: from the SAME 4-layer additive combine as
hooks: (reyn.yaml startup ∪ .reyn/config/hooks.yaml runtime ∪
per-agent ∪ per-session) and starts every configured Composer automatically
(start_composers, called from run() alongside the filesystem watcher's
own start) — no manual wiring required. Composers are startup-only: a
config change takes effect on the next session start, not via the hooks
hot-reload seam (a live Composer's in-flight PendingStore correlation
state has no reload-time reconciliation yet).
A Composer's per-key pending state lives behind the PendingStore seam, and
which implementation a composer gets is a per-composer decision (durable:,
defaulting to true for op: deadline and false for every other op):
| Store | On a process crash | |
|---|---|---|
durable: false (default for 7 ops) |
InMemoryPendingStore |
in-flight correlations are dropped — costs at most one buffered notification |
durable: true (default for deadline) |
DurablePendingStore |
the armed set is restored with its original arm instants, so a missed deadline still fires at armed_at + ttl |
deadline is the asymmetric case, which is why it alone defaults to durable:
losing a debounce buffer costs one notification, but losing an armed
deadline costs the monitoring itself — and whatever the dead-man switch was
watching is very likely inside the same crash. Restoring the arm instant is
the load-bearing half: a monitor re-armed with a fresh clock reports healthy
while silently sliding its deadline forward by the entire downtime.
DurablePendingStore is a full-state JSON snapshot at
<per-session state dir>/composer_pending.json, rewritten atomically on every
arm/disarm — deliberately not WAL-events. WAL-derived state that is not
snapshot-backed is silently lost when the WAL is truncated below its source
events (the #2259 config-recovery class),
and a dead-man switch that silently fails to re-arm is the worst instance of
that. Because the store never reads the WAL, it survives truncation
structurally rather than by argument.
Setting durable: false on a deadline composer is allowed — an in-process
nudge does not need an fsync per arm — but emits a load-time UserWarning, so
a dead-man switch that dies with its process is never a silent posture.
The composed→wake loop-valve bound. A composed:<name> hook's
wake=true push lands in the inbox via the exact same kind="hook" E-path
every other hook-driven wake uses, so a self-stimulating composed→wake
chain (a composer counting a lifecycle point its own consumer hook's next
turn re-triggers — e.g. turn_end) is bounded by the session's existing
max_hook_driven_turns loop-valve with zero new bounding logic: every
wake path, composed→wake included, is counted by the same
_hook_driven_turns cap check. This is the architect-ratified "structural
non-reentry → valve-metered allow" transition (proposal
0059 §9 item 3) —
pinned by a flip-witness Tier-2 test that drives a chain whose natural turn
count is unbounded and asserts the force-close fires at the cap.
Everything above is authored by the operator in reyn.yaml (the OUT-set,
restart-only) or emitted by the OS/Composer. hooks_add is the tool that lets
the agent add a hook to its own runtime layer at any point during a
session — self-directed continuation (wake: true) or recurring injected
context (wake: false), without a restart.
{"kind": "hooks_add", "on": "turn_end", "message": "Check the deploy status.", "wake": true}on(required) — one of the lifecycle points above:turn_start,turn_end,session_start,session_end.message(required) — the push message (Jinja2 template allowed).wake(optional, defaulttrue) —truestarts a new turn (self-continuation, bounded bysafety.loop.max_hook_driven_turns);falserides along as context with the next turn.push_when(optional) — a Jinja2 → bool guard; the push is skipped when it renders false.name(optional) — a label surfaced as a[hook:name]attribution prefix in history.
Write target and gating: the hook is written to one of exactly two hardcoded runtime IN-set targets — never derived from LLM input, only from the calling session's own identity (#2088):
- the default/unnamed agent writes the GLOBAL
.reyn/config/hooks.yaml; - a named-agent session writes ITS OWN per-agent layer
.reyn/agents/<name>/hooks.yaml— the same file an operator-authored per-agent hooks file already lives at.
Either way this structurally can never touch reyn.yaml (the OUT-set) nor
another agent's per-agent layer. The hook joins the existing hooks
ADDITIVELY (the two scopes never override one another) and takes effect at
the next turn boundary. The tool itself is write-gated (permissions.tool)
and can be denied per-agent via a capability profile's tool_deny. Full
hot-reload mechanics — the layered COMBINE, validate-before-apply, boot
resilience — are covered in
Concepts: Config hot-reload.
Decision, not an oversight: the default agent writes the GLOBAL layer
(.reyn/config/hooks.yaml) for backward compatibility and the unnamed scope
— .reyn/agents/default/hooks.yaml is read (an operator can hand-place
one there and it is combined in), but hooks_add never generates one, by
design.
Everything above is fired by the OS (a lifecycle point, an external-event
source) or by a Composer's correlation logic. emit_hook_event is the ONE
Control-IR op that lets the LLM itself put an event onto its own session's
Bus — the first LLM-reachable producer in an otherwise OS-internal pipeline.
{"kind": "emit_hook_event", "event_name": "deploy_ready", "payload": {"artifact": "build-42"}}event_name(str, required) — the emitted kind is ALWAYSllm:<session_id>:<event_name>; the session component comes solely from the caller's own session at execution time — there is no field the LLM can set to target a different session.payload(dict, optional, default{}) — carried on the event for amatcher/ Composer to inspect. Never itself rendered into a hook message template by this op.
The autonomy boundary — a static ALLOW-list, not a deny-list. Only this
session's own llm:<session_id>:* namespace may ever be emitted:
builtin:*is rejected — an LLM cannot spoof Reyn's own lifecycle/external-event kinds.composed:*is rejected — an LLM cannot spoof a Composer's correlated output; forging one would fire acomposed:*-gated hook (e.g. an approval-gated deploy) without the Composer's actual correlation logic ever running.webhook:*/mcp:*(or any other session'sllm:*) are rejected — an LLM cannot spoof external ingress or another session's identity.
An emitted llm:* event only reaches a hooks: entry through a
Composer — there is no direct Sync path from a raw llm:* Bus event to a
hooks: on: entry (only composed:* events are bridged to Sync dispatch;
see Async Bus and Composer).
So emit_hook_event's output is always consumed as a Composer
inputs[].kind, never directly as a hook's on:. The inputs[].kind
value must name the actual session id, not just the event name — the
default session's id is main (see Sessions
for named/multi-session setups). Worked example — the LLM signals it
finished preparing a deploy (with the artifact id in payload); a Composer
waits for that alongside an external approval, then a Sync hook reacts to
the correlated result and reads the artifact id back out. Using op: seq
here (not all) is deliberate — it's the one op that GUARANTEES inputs[]
lands in the declared order, so inputs[0] is reliably the llm:* event's
payload, not whichever of the two happened to arrive first:
composers:
- name: deploy_approved
op: seq # order-guaranteed — inputs[0] is always the llm:* event below
inputs:
- kind: llm:main:deploy_ready # the default session's id is "main"
- kind: mcp:approval-server:approved
emit: { kind: composed:deploy_approved }
hooks:
- on: composed:deploy_approved
template_push:
message: "Deploy artifact {{ inputs[0].artifact }} approved — proceeding."
wake: falseThe following capabilities are designed but not yet implemented:
- Agent-level and phase-level hooks — fine-grained points inside a turn (rare use cases; session/turn covers the common ones).
- valve-persist —
_hook_driven_turns(the loop-valve counter) is in-memory-only (resets on crash); a separate, recovery-gated follow-up would make it snapshot-backed. Flagged as more load-bearing now that the Composer/Bus redesign adds new hook-driven-turn-generating paths.
- Workspace — the single source of truth that hook push messages land in
- Events — the P6 audit trail that records hook dispatch
- Permission model — the consent flow for shell hooks
- Sandbox — the backend-agnostic execution environment for shell hooks
- reyn-yaml § hooks — full config reference
- MCP § Resource subscriptions — the source of the
mcp_resource_updatedexternal-event point - Pipelines — what a
pipeline_launchhook launches