Skip to content

Hooks

A hook reacts to what other components do: it declares what to watch, which outcomes matter, and owns a side effect. Like a job's cron, the declaration is inert intent that a platform scheduler evaluates against finished runs. The core defines the classes and the contract.

Declaring a hook

import interloper as il

alert = il.WebhookHook(
    watches=[ingest_job],
    events=["run_failed"],
    url="https://ops.example.com/alerts",
)
Field Default Meaning
watches [] Sources, assets or jobs to observe.
events ["run_failed"] Outcomes that fire the hook: run_completed, run_failed, backfill_completed, backfill_failed. At least one.
enabled True A disabled hook is kept but never fires.

Built-in hooks

TriggerHook runs other components when a watched outcome matches. It is the primitive for cascading pipelines:

il.TriggerHook(watches=[ingest_job], targets=[transform_job], events=["run_completed"])

Its targets are sources, assets or jobs. Firing goes through the trigger capability the operator injects into the context, so the hook has no persistence dependency of its own; firing without one raises ConfigError.

WebhookHook POSTs a fixed JSON document to url with a timeout (default 10 seconds). A non-success response raises:

{
  "event_type": "run_failed",
  "component_id": "…",
  "run_id": "…",
  "partition_key": "2026-01-15",
  "backfill_id": null,
  "start_key": null,
  "end_key": null,
  "url": "https://app.example.com/executions/runs/…",
  "hook_id": "…",
  "metadata": {}
}

Custom hooks

Subclass il.Hook and implement fire(context). Configuration fields and relations work as on any component, so credentials ride a connection:

class EmailHook(il.Hook):
    recipient: str = il.InputField(description="Address to notify")
    smtp: SmtpConnection

    def fire(self, context: il.HookContext) -> None:
        self.smtp.send(self.recipient, f"{context.event_type} on {context.component_id}")

HookContext carries:

Attribute Meaning
event_type The matched outcome.
component_id The watched component whose run ended.
run_id The run.
partition_key The run's partition key, if any.
backfill_id, start_key, end_key The backfill and its range, on a backfill event.
url The run's or backfill's page in the app, when server.external_url is set.
metadata Event details supplied by the operator: always status, organisation_name, component_name and component_key, plus what the event kind adds below.
trigger A callable creating a run for a component id, when the operator provides it.

A hook that acts on other components extends the relation vocabulary the way TriggerHook does, declaring its own targets relation on the subclass; the base hook only observes. Custom hook classes join the catalog through the interloper.components entry point like any component.

HookState (last_fired_at, last_run_id, last_error) is the hook's machine-owned state, stamped by the operator on every firing; last_error holds the latest firing's error and a successful firing clears it.

Backfills

A backfill has a verdict of its own: backfill_completed once every partition's latest attempt succeeded, backfill_failed once the batch closed with a failed partition left. Every firing of a partitioned cron job is a backfill, so a hook subscribed to backfill_failed gets one message per bad morning rather than one per partition; subscribe to both levels to get both. A canceled backfill is not an outcome of the work and fires nothing.

The context names the backfill (backfill_id, start_key, end_key) and its metadata carries partitions, counts (partitions per status, each read as its latest attempt) and, for a failure, failed_partitions: [partition_key, error] pairs, newest first. A TriggerHook fired by a backfill event backfills each target over the same range, gated by the target job's concurrency.

Retries

A hook observes a verdict, never an attempt. When a run fails and its job's retry policy allows another attempt, the failure is not an outcome and no hook fires. Only the attempt that ends the stack does: as run_completed if a retry healed the work, or as run_failed once the budget is exhausted. A backfill stays open while a retry waits out its backoff, so its event, too, reports the healed or exhausted stack.

The context carries the stack's position, so a message can say which attempt it is reporting:

def fire(self, context: il.HookContext) -> None:
    attempt = context.metadata["attempt"]
    attempts = context.metadata["attempts"]
    post(f"{context.metadata['component_name']}: {context.metadata['status']} on attempt {attempt}/{attempts}")

Scope

Hooks fire on persisted, terminal runs evaluated by a scheduler. For observing execution inside a process (asset lifecycle, destination I/O, log lines), use the event bus.