Skip to main content
A hook binds an inbound event topic to a tool. When an external system delivers an event on a topic, the hooks manager keeps the event as one run per hook registered for that topic, then runs each hook whose condition matches the payload, with its bound tool in the background. A run that fails before its hook acts, for a reason that can pass, is retried; a run that fails after, or for a reason no retry can change (such as a condition that cannot evaluate), is held as failed for an operator (Kept and retried). This page explains the model. To register one, use the Studio’s Hooks screen or follow Fire a tool from a webhook.

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:
Registering a hook checks what it names. An unknown 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_refs extractor names its references in its own words, printed as given — for example, a config that calls ghost at its step fetch is refused with composed tool 'ghost' at tool_kwargs > step 'fetch' is not a registered tool;
  • an agent whose tool_kwargs.tool_names is ["ghost"] is refused with tool_kwargs.tool_names entry 'ghost' is not a registered tool;
  • a toolbox chain branch t_chain whose jq_expression is {"id": "jq/missing"} is refused with stored id 'jq/missing' at tool_kwargs > jq_expression is not a stored template, read from the branch’s own input annotations.
The condition is what makes dispatch selective — a hook fires only when its condition matches the event payload, so one topic can route different events to different tools. A hook is also a parkable door: beside 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’s topic 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 public POST /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.
/universal_webhook/{topic} is the most exposed door on a server. A topic with no verifier binding is open by design; bind a webhook verifier to a topic to authenticate every inbound event over its raw body before it is parsed and dispatched. The public-door flood limiter throttles the door regardless.

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_key extra (TOOL_EXTRA_IDEMPOTENCY_KEY), and only when the tool declares TOOL_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_SECONDS to finish, before anything else drains their tools.
  • A tool that raises its own CancelledError fails its run (kind cancelled). Only a shutdown or a config apply that drains the tool counts as an interruption.
A stopped claim is never re-run blindly. A run stopped while its hook was making a resume or take claim first checks, on its next attempt, whether the entry is still open. Still open means the claim did not commit, and the run is retried automatically. No longer open means the entry was resolved, and the platform cannot tell whether this run’s claim or another door (an answer, an expiry, another caller, the retention sweep, a TTL) resolved it. The run is then held as failed, and its error says so and lists the ids. Check the interaction, then discard the run, or retry it as a fresh run whose checks and door contract decide again. Every attempt counts. Every attempt counts toward 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).
A retry replays the start of a run that had started its tool, and otherwise runs the hook again. A run whose hook committed a resume or a take cannot be retried, only discarded, and the refusal says why. The same doors are 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 subject kind, and a key_expr — a jq expression evaluated over the event payload at fire, which must yield a non-empty string:
The fire runs with this subject as its ambient state context: a state tool the hook calls resolves the subject from it, and the write is recorded under door 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
Setting or removing a topic’s verifier binding is a separate authed door (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.