$npx -y skills add astronomer/agents --skill airflow-hitlBuilds human-in-the-loop (HITL) Airflow workflows - approval gates, form input, and human-driven branching. Use when a DAG needs a human in the loop - an approval or reject step, sign-off before a task runs, a decision or approval UI, branching on a human choice, or collecting fo
| 1 | # Airflow Human-in-the-Loop Operators |
| 2 | |
| 3 | Pause a DAG until a human responds via the Airflow UI or REST API. HITL operators are deferrable — they release their worker slot while waiting. |
| 4 | |
| 5 | > **Requires Airflow 3.1+** (`af config version`). |
| 6 | > |
| 7 | > **UI location**: Browse → Required Actions. Respond from the task instance page's Required Actions tab. |
| 8 | > |
| 9 | > **Cross-references**: `migrating-ai-sdk-to-common-ai` for AI/LLM task decorators; `airflow` for registry and API discovery commands used below. |
| 10 | |
| 11 | --- |
| 12 | |
| 13 | ## Step 1 — Pick the capability you need |
| 14 | |
| 15 | | Capability | Class (verify in Step 2) | |
| 16 | |---|---| |
| 17 | | Approve or reject; downstream skips on reject | `ApprovalOperator` | |
| 18 | | Present N options and return which were chosen | `HITLOperator` | |
| 19 | | Branch to one or more downstream tasks based on a choice | `HITLBranchOperator` | |
| 20 | | Collect a form (no approve/select step) | `HITLEntryOperator` | |
| 21 | | Use the HITL trigger directly (advanced / custom operators) | `HITLTrigger` | |
| 22 | |
| 23 | This is the only place class names are hardcoded. The provider adds, renames, and removes params across releases — do not copy parameter lists from memory. Fetch the current signature before writing code. |
| 24 | |
| 25 | --- |
| 26 | |
| 27 | ## Step 2 — Discover the current signatures from the Airflow Registry |
| 28 | |
| 29 | Before writing HITL code, run these to see the live roster and constructor params (see the `airflow` skill for the full `af registry` reference): |
| 30 | |
| 31 | ```bash |
| 32 | # Every HITL-related module in the standard provider |
| 33 | af registry modules standard \ |
| 34 | | jq '.modules[] | select(.import_path | test("\\.hitl\\.")) | {name, type, import_path, short_description, docs_url}' |
| 35 | |
| 36 | # Constructor signatures: name, type, default, required, description |
| 37 | af registry parameters standard \ |
| 38 | | jq '.classes | to_entries[] | select(.key | test("\\.hitl\\.")) | {fqn: .key, parameters: .value.parameters}' |
| 39 | |
| 40 | # Pin to the exact installed provider version |
| 41 | af config providers \ |
| 42 | | jq '.providers[] | select(.package_name == "apache-airflow-providers-standard") | .version' |
| 43 | # then: af registry parameters standard --version <VERSION> |
| 44 | ``` |
| 45 | |
| 46 | If the registry shows a param that this skill does not mention, prefer the registry. If the registry shows a class that is not in Step 1, treat it as additive — the decision table above may be stale. |
| 47 | |
| 48 | --- |
| 49 | |
| 50 | ## Step 3 — Canonical example (approval gate) |
| 51 | |
| 52 | Starting point for any HITL task. Adapt by swapping the class name and params per Step 2. |
| 53 | |
| 54 | ```python |
| 55 | from airflow.providers.standard.operators.hitl import ApprovalOperator |
| 56 | from airflow.sdk import dag, task, chain, Param |
| 57 | from pendulum import datetime |
| 58 | |
| 59 | @dag(start_date=datetime(2025, 1, 1), schedule="@daily") |
| 60 | def approval_example(): |
| 61 | @task |
| 62 | def prepare(): |
| 63 | return "Review quarterly report" |
| 64 | |
| 65 | approval = ApprovalOperator( |
| 66 | task_id="approve_report", |
| 67 | subject="Report Approval", |
| 68 | body="{{ ti.xcom_pull(task_ids='prepare') }}", |
| 69 | defaults="Approve", # Auto-selected on timeout |
| 70 | params={"comments": Param("", type="string")}, |
| 71 | ) |
| 72 | |
| 73 | @task |
| 74 | def after_approval(result): |
| 75 | print(f"Decision: {result['chosen_options']}") |
| 76 | |
| 77 | chain(prepare(), approval) |
| 78 | after_approval(approval.output) |
| 79 | |
| 80 | approval_example() |
| 81 | ``` |
| 82 | |
| 83 | For the other classes in Step 1, the shape is the same (`task_id`, `subject`, plus class-specific params). Verify each constructor through Step 2 — for example, `HITLBranchOperator` requires every option either to match a downstream task id directly or to be resolved via a mapping param surfaced in the registry. |
| 84 | |
| 85 | --- |
| 86 | |
| 87 | ## Step 4 — Behavior contracts (stable across versions) |
| 88 | |
| 89 | ### Timeout |
| 90 | - With `defaults` set: task succeeds on timeout, default option(s) selected. |
| 91 | - Without `defaults`: task fails on timeout. |
| 92 | |
| 93 | ### Markdown + Jinja in `body` |
| 94 | `body` supports Markdown and is Jinja-templatable. Render XCom context directly: |
| 95 | |
| 96 | ```python |
| 97 | body = """**Total Budget:** {{ ti.xcom_pull(task_ids='get_budget') }} |
| 98 | |
| 99 | | Category | Amount | |
| 100 | |----------|--------| |
| 101 | | Marketing | $1M | |
| 102 | """ |
| 103 | ``` |
| 104 | |
| 105 | ### Callbacks |
| 106 | All HITL operators accept the standard Airflow callback kwargs (`on_success_callback`, `on_failure_callback`, etc.). |
| 107 | |
| 108 | ### Notifiers |
| 109 | HITL operators accept a `notifiers` list. Inside a notifier's `notify(context)` method, build a link to the pending task with `HITLOperator.generate_link_to_ui_from_context(context, base_url=...)`. |
| 110 | |
| 111 | ### Restricting who can respond |
| 112 | The parameter name and accepted identifier format depend on the active auth manager. Do **not** hardcode — check which one is a |