The dispatch model
A hook is a small record: a topic, the tool to run, the static keyword arguments to pass it, an optional condition, and the jqs that shape and drive the fired run. Registration is keyed by topic, so one topic can drive several hooks and the manager dispatches to all of them: an event on a topic becomes one hook run per hook, kept in the hooks store until it finishes:tool is a 400
(hook tool 'ghost' is not a registered tool), and the static tool_kwargs are checked
the way a preset body is,
whatever kind of tool the hook targets, with one 400 line per miss:
- a base tool that declares a
tool_refsextractor names its references in its own words, printed as given — for example, a config that callsghostat its stepfetchis refused withcomposed tool 'ghost' at tool_kwargs > step 'fetch' is not a registered tool; - an agent whose
tool_kwargs.tool_namesis["ghost"]is refused withtool_kwargs.tool_names entry 'ghost' is not a registered tool; - a toolbox chain branch
t_chainwhosejq_expressionis{"id": "jq/missing"}is refused withstored id 'jq/missing' at tool_kwargs > jq_expression is not a stored template, read from the branch’s own input annotations.
condition it carries the door
contract — start_expr builds the
fired tool’s kwargs from the payload (the static tool_kwargs win on a key clash;
absent, the tool fires with tool_kwargs only), and cancel_expr / resume_expr /
extras_expr cancel, resume, or hand extras to the run’s parked
interactions. A hook fire has no receiver, so a resumed
run’s terminal waits on the subject for
its next owner to take.
condition and the door-contract jqs are jq, and they say so in the schema: each
carries the x-tai42-expression annotation,
which names the input document and what the result is consumed as, and binds the run’s
parked interactions as $parked. An editor reads that
annotation and offers the visual jq editor on the field
without being told which fields are expressions.
Naming a hook and topic
A hook’stopic is dispatched as one path segment of the public webhook URL
(/universal_webhook/{topic}), and its name addresses the hook on every
management and delete door. Both must be a single URL-safe path segment: match
^[a-z0-9][a-z0-9_-]*$ — begin with a lowercase letter or digit, then lowercase
letters, digits, underscores, and hyphens only. No dots, slashes, uppercase, or
whitespace — a / or a stray character would mint a record no delivery could
route to and no door could address. The value is never sanitized: a violation is
rejected at registration with a 400, its message naming the offending field and
the pattern.
A hook that carries a state binding has it checked and its templates attached at
registration; a refused binding stores no hook. A binding naming an undeclared state is a
404, an occupied attach path a 409, and a states-store failure a 500 — see saving a
binding.
The ingress door
External systems deliver events to the publicPOST /universal_webhook/{topic}
door. The payload parser accepts any content type. Before the door answers, the
event is kept as one hook run per hook on the topic, so a 200
{"status": "accepted"} means the event is kept: its runs then run, and are
retried, in the background, and the ingress never waits for the tools to finish.
When the hooks store refuses to keep the runs, the door answers a 500 and keeps
nothing, so the sender’s own retry delivers the event again. The body is
{"error": "accept failed"}, or {"error": "webhook verification error"} on a
topic whose verifier claims each delivery id; that claim is not kept either, so
the retry is not refused as already seen.
A multipart form whose parts include a file is answered 400
({"status": "rejected", ...}), here and on a trigger link, and nothing is kept:
a kept hook run carries text fields only.
The in-memory hooks manager keeps its runs in the serving process only: they are
lost when that process stops, and a sibling worker never sees them. A deployment
that relies on kept runs uses the Redis-backed manager.
Kept and retried
Every event the ingress accepts is kept, one run per hook on the topic. A run lives in the hooks store until it finishes, or until an operator retries or discards it once it is held as failed. When the hook acts. The hook acts when its visit cancels a parked interaction, commits a resume or take claim, or starts the tool. Before that point every failure is retried automatically, with backoff (HOOKS_RUN_RETRY_BASE_SECONDS, doubled per
attempt, capped at HOOKS_RUN_RETRY_CAP_SECONDS), up to HOOKS_RUN_MAX_ATTEMPTS
attempts in all. That covers the condition, the state-context render, the $parked
read, the door contract, the visit’s own checks and the wait for pending saves, when
they fail for a reason that can pass, such as a store out of reach. The failure kinds
no retry can change (bad input, unauthorized, not found, conflict, cancelled) fail the
run at once.
A condition that raises when jq evaluates it over the run’s payload is bad input: the
same payload raises again on every attempt. The run fails after one attempt, is kept in
the failed set and reported like any failed run, and its tool never runs. Fix the
condition by registering the hook again under the same name, then retry the run; the
retry reads the hook as it is registered now.
After the hook acts. Once the hook acts, the platform adds no automatic attempts.
A tool that wants its own failures retried declares a ToolRetryPolicy, and exactly
that policy runs. Any failure after that point holds the run as failed.
Interrupted runs. A run can be interrupted by a crash or a lost lease, by a
graceful shutdown, or by a config apply that retires the serving generation while the
hook’s tool runs.
- A run interrupted after it started a tool replays that start alone, with the same
input, the same extras and the same
tai42/idempotency_keyextra (TOOL_EXTRA_IDEMPOTENCY_KEY), and only when the tool declaresTOOL_META_CRASH_RESUME. Otherwise it is held as failed. - Any other run interrupted after its hook acted is held as failed.
- A shutdown first gives the hook runs in flight
HOOKS_RUN_SHUTDOWN_GRACE_SECONDSto finish, before anything else drains their tools. - A tool that raises its own
CancelledErrorfails its run (kindcancelled). Only a shutdown or a config apply that drains the tool counts as an interruption.
HOOKS_RUN_MAX_ATTEMPTS, an
interrupted one and a replayed start included, so a run that keeps meeting restarts or
config applies ends in the failed set instead of repeating.
A resumed run’s own redelivery belongs to the interactions reaper (see sync and
async). A taken outcome is consumed when it is
taken.
Across restarts and replicas. A running attempt holds a lease
(HOOKS_RUN_LEASE_SECONDS, renewed while it runs). Every serving process sweeps the
store every HOOKS_RUN_SWEEP_SECONDS: it runs the runs that are due, and takes over the
runs whose lease lapsed, so a run of a process that crashed is picked up by a restarted
process or by another replica.
How many run at once. HOOKS_MAX_WORKERS bounds the hook runs in flight per
process. A config apply that changes it applies from the next run admitted; it never
cancels a run in flight. Runs begun before a config apply keep their shutdown grace.
A failed run. A run held as failed stays in the store until an operator retries or
discards it. The platform logs it at ERROR, sends an operator notification, fires the
hooks_run_failed platform event, and counts it in the
tai42_hook_runs_* metrics. The run list shows, per run, acted (what the hook had
done: "", cancel, resume, take or start), acted_ids (the interactions it
acted on) and claim_intent (a claim it was making when it stopped).
GET /api/hook-runs,
POST /api/hook-runs/{id}/retry and DELETE /api/hook-runs/{id}.
The settings are listed under HooksSettings.
Targeting a state subject
A hook may carry an optional subject so a state write during the fire is keyed and attributed to the hook. The subject names a conversation-target scope, a subjectkind, and a key_expr — a jq expression
evaluated over the event payload at fire, which must yield a non-empty string:
hook with the hook’s execution key as the actor. A key_expr that yields nothing
fails the fire loudly, like any hook error. Leaving subject unset leaves the fire
with no ambient state context, so a state tool it calls must carry an explicit
subject. Like the hook’s other jq fields, key_expr is a templated text: inline jq
({"content": …}) or a stored template by id with its kwargs. The Studio’s Hooks
screen surfaces the subject as an optional group on the register and
edit form, whose key field takes either form.
Platform events
The platform also fires topics of its own, for a background event an operator should hear about. A hook registered on such a topic fires exactly as it does for an external event, so a deployment routes the alert wherever it wants — a chat-line, a pager, a ticket. Among them:
A failure to fire a platform event is logged at WARNING and never stops the work that raised
it; the event’s cause is already logged on its own.
Managing hooks live
Hooks are managed at run time, with no restart. The manager comes in an in-memory and a Redis-backed form (HOOKS_REDIS_URL, or TAI_DEFAULT_REDIS_URL); the in-memory
form is per-process, so under tai serve with more than one worker each worker
refuses to boot on the in-memory form (a reload or config write that would leave it
in-memory is refused too), and a config that registers a task
backend is refused over the in-memory form at any worker count (the backend process matches the events it raises
against its own registry, which would hold none of the hooks registered through the
server). The authed management doors (GET/POST/DELETE
/api/hooks) are a thin skin over it — the same manager the hook-management
operations drive. Register and remove hooks from the
CLI:
register takes the whole hook record — topic, tool, kwargs, and an optional
condition — as one --params JSON object; its full option set is on the command
itself:
examples/hooks/register_hook.sh
PUT/DELETE /api/hooks/topics/{topic}/verifier). The kept runs have their own
authed doors — GET /api/hook-runs, POST /api/hook-runs/{id}/retry and
DELETE /api/hook-runs/{id}, driven by tai hooks runs list|retry|discard (see
Kept and retried).
See the fire-a-tool-from-a-webhook guide for
the binding-and-condition workflow, triggers and webhook
verifiers for authenticating the ingress, and
the HTTP API reference for the hook routes.
