Outbound hooks¶
An outbound hook is a named subscription: when an engine event matches one of the hook's selectors, Flux starts a target workflow with a redacted envelope of that event as its input.
flux hook create notify-approvals \
--on "task:release:*:promote_prod:awaiting_approval" \
--workflow ops/notify_slack \
--principal ops-sa
From then on, every time the promote_prod task in a release/ workflow
pauses for approval, ops/notify_slack runs with the event as its input —
and posts to Slack, opens a ticket, or whatever else its tasks do.
Hooks are outbound only. Ingress into a running workflow is already covered by resume-with-input, approval decisions, and workflow services, so there is no inbound hook entity.
Why the only action is running a workflow¶
There is one action, run_workflow. Delivering to Slack, PagerDuty or a
GMUD system is what the target workflow does, with ordinary tasks —
because everything a direct webhook action would need already exists as a
workflow feature:
| Webhook delivery needs | The target workflow already has |
|---|---|
| HTTP retry policy | retry_max_attempts / retry_delay / retry_backoff, plus fallback and timeout |
| Credential storage and signing | secret_requests against the encrypted secret store |
| Body templating | plain Python in the task body — no interpolation language, no injection surface |
| A deliveries log to debug from | the delivery is an execution: its events, retries and failure are inspectable like any other run |
So "delivery" collapses into creating an execution — a local database write
with no third party on the path. The cost is one execution per fired event,
heavier than one POST; durability="transient" on the notification workflow
and the retention sweep bound what that accumulates.
Selector grammar¶
A selector is a :-delimited string naming an engine event. It uses the
same wildcard rules as permissions: a terminal * matches any number of
remaining segments, a * anywhere else matches exactly one. Event keys have
a fixed width per domain, so a selector is rejected at create time if it is
the wrong width — including one that is too long before a trailing *
(execution:a:b:c:d:*), which would otherwise be accepted as a hook that can
never fire.
execution:<namespace>:<workflow>:<state> # ExecutionState transitions
task:<namespace>:<workflow>:<task>:<event> # task events
States and event types are matched by their lower-cased value — paused,
completed, failed, awaiting_approval, approved, rejected,
retry_started, and so on. Approvals need no domain of their own: the
approval lifecycle is already three task event types.
execution:* # every state transition
execution:*:*:failed # any workflow fails
execution:release:*:paused # anything in release/ pauses
task:release:*:promote_prod:awaiting_approval # one gate, fleet-wide
task:release:*:*:rejected # any rejection in release/
A hook carries a list of selectors, exactly as a role carries permissions, and fires once for an event matching any of them — several selectors are an OR, not a fan-out. "Global vs. workflow-specific" is not a mode, it is wildcard width.
Wide selectors are loud
Every selector can fire; no event class is whitelisted. task:*
subscribes to every task event of every execution, and each match writes
a delivery row and starts an execution. That is legal, self-inflicted,
and visible in flux hook deliveries.
What the target receives¶
The target workflow's input is the envelope:
{
"hook": "notify-approvals",
"selector": "task:release:*:promote_prod:awaiting_approval",
"delivery_id": "…",
"event_key": "…",
"attempt": 1,
"hop": 0,
"event": {
"domain": "task",
"type": "awaiting_approval",
"execution_id": "…",
"workflow_namespace": "release",
"workflow_name": "promote_prod_pipeline",
"task_name": "promote_prod",
"task_call_id": "…",
"state": null,
"value": { "…": "the event's value" },
"occurred_at": "2026-08-14T12:00:00Z"
}
}
state mirrors type for the execution domain and is null for task.
So a target workflow is an ordinary workflow taking a dict:
from typing import Any
from flux import task, workflow, ExecutionContext
@task.with_options(secret_requests=["slack-webhook"])
async def post_to_slack(text: str, secrets: dict[str, Any] = {}) -> None:
import httpx
await httpx.AsyncClient().post(secrets["slack-webhook"], json={"text": text})
@workflow
async def notify_slack(ctx: ExecutionContext[dict]):
event = ctx.input["event"]
await post_to_slack(
f"{event['workflow_name']} is {event['type']} "
f"(execution {event['execution_id']})",
)
Redaction. The envelope is passed through the redaction module when it
is built, so known secret values are scrubbed before the payload is stored
or handed on. Values from sensitive tasks are already [REDACTED:sensitive]
at storage and never reach the envelope at all.
Delivery semantics¶
Firing is a transactional outbox. The delivery row is written in the same transaction as the event it reports, so no event is ever missed and no delivery ever blocks a checkpoint. The drain then runs on the scheduler tick, under the same cross-replica lock as the other sweeps, and turns each due row into an execution.
Two consequences worth planning around:
- Latency is a scheduler tick, not a millisecond.
[flux.scheduling] poll_interval(default 30s) bounds it. - Deliveries are at-least-once. Retries use exponential backoff, and a
row goes
deadafter the hook'smax_attempts. Target workflows should be idempotent perevent_key— the same discipline the replay model already demands. Deduplicating onctx.input["event_key"](or using it as an idempotency key against the system being called) is the intended pattern; the key is"<execution_id>:<event_id>", so it identifies one event of one execution.
Some failures are terminal rather than retried, because retrying only buries the audit trail:
| Situation | Outcome |
|---|---|
| Target workflow not in the catalog | dead, with the error recorded |
| Principal disabled, or lacking run permission at fire time | dead |
| Hook disabled while deliveries were queued | dead — enabled=false is a stop button, and one that lets the backlog drain is not one |
| Hop limit reached | dead, before anything is authorized or created |
| Anything else (busy database, transient read) | retried with backoff until max_attempts |
There is no cross-delivery ordering. Per-execution ordering may be added later; it is not promised now.
The hop guard¶
The envelope carries a hop. A delivery for an event from an execution no
hook started is hop 0; an execution started by a hook stamps hop + 1 onto
the deliveries its own events produce. [flux.hooks] hop_limit (default 3)
is the number of links a chain may have: hops 0, 1 and 2 fire, and a
delivery that has reached the limit is dead-lettered with a loop error.
Without it, execution:*:*:completed targeting any workflow is a fork bomb —
each completion starts an execution whose completion starts another.
An execution counts as hook-started only when its input carries the whole
envelope shape (delivery_id and event_key alongside hop), and a hop
below zero is read as zero. Neither is cosmetic: a workflow whose ordinary
input happens to have a hop key would otherwise have its first deliveries
dead-lettered as a loop, and anyone able to start an execution with a chosen
input could hand themselves a chain of a thousand generations.
Inline executions¶
wf.run(...) — the inline path used by scripts and tests/examples/ —
persists events through the same door, so it enqueues deliveries
normally. But the drain lives in the server's scheduler tick, so those
rows stay pending until a server runs against the same database. Hooks are
a distributed-path feature in practice; an inline run alone will not deliver
anything.
Permissions¶
House grammar, hook:<name>:<verb>:
| Method | Path | Required permission |
|---|---|---|
POST |
/hooks |
hook:*:create |
GET |
/hooks |
hook:*:read |
GET |
/hooks/{name} |
hook:{name}:read |
PUT |
/hooks/{name} |
hook:{name}:update |
DELETE |
/hooks/{name} |
hook:{name}:delete |
POST |
/hooks/{name}/test |
hook:{name}:update |
GET |
/hooks/{name}/deliveries |
hook:deliveries:read |
POST |
/hooks/{name}/deliveries/{id}/retry |
hook:deliveries:retry |
Built-in roles: admin covers everything through *; operator gets full
hook management (hook:*, hook:deliveries:read, hook:deliveries:retry);
viewer gets hook:*:read and hook:deliveries:read; worker gets
nothing.
The hook's principal. A hook stores the principal it runs its target
as: a service account, named by subject — ops-sa, the same handle
flux principals and the permission grammar use, and the same thing a
schedule stores for its service account. It is stated explicitly, never
inferred from whoever created it, since the hook outlives that request. Each
way of naming an unusable one gets its own answer rather than a permissions
error: no such subject, not a service account, disabled, banned. That
principal must also hold run permission on the target workflow, and that
check runs twice: at create/update, so a misconfiguration fails at the door,
and again at fire time, so a later revocation dead-letters deliveries
instead of silently bypassing RBAC.
Acting through one is impersonation. Every delivery runs the target
under that principal's roles, with an execution token minted for it, so it
takes principal:<subject>:impersonate on top of the hook permission —
otherwise hook:* on its own would be a route to anyone else's privileges.
The rule is one sentence: anything that can cause an execution to run as
the hook's principal requires the right to borrow it; disabling and deleting
never can. In practice:
| Action | Needs the grant |
|---|---|
flux hook create --principal ops-sa |
yes |
flux hook update --principal other-sa (rebind) |
yes, against the incoming principal |
flux hook update --workflow … / --on … (re-aim) |
yes — it runs the principal the hook already carries against a target you chose |
flux hook update --enable |
yes — a disabled hook is inert, so this resumes automatic firing |
flux hook test |
yes — it really does start the target as that principal |
flux hook retry <delivery> |
yes — the next tick fires it as that principal |
flux hook update --disable |
no |
flux hook update --max-attempts |
no |
flux hook get / list / deliveries |
no |
flux hook delete |
no |
Turning a hook off, and deleting it, are the stops — they must work even
when the bound identity is exactly what has gone wrong. Re-sending values a
hook already has (its current principal, or --enable on a live hook)
changes nothing and so needs nothing.
Because a hook observes events and acts under a stored principal,
hook:*:create sits above workflow:*:register in the hierarchy — creating
one is a privilege decision, not a convenience.
What the grant does not cover. It governs configuring a hook and
firing one out of band. It cannot govern the in-band path, because that path
is the feature: anyone who can run a workflow that emits a matching event
causes a delivery, and the target then runs under the hook's principal with
that event's value in its envelope. A wildcard selector like
execution:*:*:failed is therefore reachable by anyone who can make any
workflow fail — a broader surface than flux hook test, which the grant does
gate. Two consequences worth designing around:
- Scope selectors as narrowly as the job allows. Exposure scales with
selector breadth, not with who holds
hook:*. - Treat the envelope as untrusted input. Its
event.valueis whatever the observed execution produced, and the observed execution may have been started by someone with far less authority than the hook's principal.
What still bounds it: the principal can only run what it may run (checked
again at fire time), and the hop guard stops a chain from feeding itself.
Revoking principal:<subject>:impersonate stops future configuration, not
firing — to stop a hook, disable or delete it.
CLI¶
flux hook create <name> --on <selector> --workflow <ns/name> --principal <subject> \
[--on <selector> ...] [--max-attempts 5]
flux hook list [--enabled-only]
flux hook get <name>
flux hook update <name> [--on ...] [--workflow ...] [--principal ...] \
[--max-attempts N] [--enable | --disable]
flux hook delete <name> [--yes]
flux hook test <name> # fire once with a synthetic event
flux hook deliveries <name> [--limit 50] # newest first, max 200
flux hook retry <name> <delivery_id> # hand a dead row back to the drain
Every command takes --format simple|json and --server-url.
flux hook test starts the target with a synthetic envelope built from the
hook's first selector, so a broken target or an under-privileged principal
surfaces before an incident does rather than during one. It writes no
delivery row and ignores enabled — a hook is tested precisely while it is
off, before it is trusted with real events.
flux hook update <name> --disable is the stop button: matching stops
immediately, and anything already queued dead-letters rather than draining.
Operating¶
A dead row is a delivery Flux gave up on. Fix the cause — re-register the
target, restore the principal's role, re-enable the hook — then hand the row
back:
Retrying resets the row to pending with a full attempt budget: it was
given up on, and an operator retrying it is saying the reason it died has
been dealt with.
Retention¶
Settled deliveries (delivered and dead) older than
[flux.retention] retention_days are removed by the retention sweep along
with expired executions. pending rows are never swept, whatever their age:
they are unmet obligations, and deleting one would drop a delivery silently
instead of dead-lettering it visibly.
Configuration¶
[flux.hooks]
enabled = true # master switch; false stops all matching
hop_limit = 3 # links a chain of hook-started executions may have
drain_batch_size = 20 # deliveries settled per scheduler tick
snapshot_ttl_seconds = 5.0 # max age of a replica's cached enabled-hook index
enabled = false stops the outbox before it matches anything; existing hook
rows are untouched, they simply stop firing. snapshot_ttl_seconds bounds
how long one replica can keep matching a hook another replica just changed —
a local write invalidates its own replica's cache immediately, and the TTL
covers the cross-replica gap.
Limitations (slice 1)¶
- Hooks are created through the API and CLI only. Declaring them on a
workflow (
@workflow.with_options(hooks=[...])) or on an agent definition is specified but not yet implemented. - No
webhookaction, and no body-templating language: external calls are the target workflow's job. - No per-execution delivery ordering.
- No console panel; the deliveries API is designed to feed one later.