$npx -y skills add astronomer/agents --skill airflow-pluginsBuilds Airflow 3.1+ plugins that embed FastAPI apps, custom UI pages, React components, middleware, macros, and operator links directly into the Airflow UI. Use when building anything custom inside Airflow 3.1+ that involves Python and a browser-facing interface - creating an Air
| 1 | # Airflow 3 Plugins |
| 2 | |
| 3 | Airflow 3 plugins let you embed FastAPI apps, React UIs, middleware, macros, operator buttons, and custom timetables directly into the Airflow process. No sidecar, no extra server. |
| 4 | |
| 5 | > **CRITICAL**: Plugin components (fastapi_apps, react_apps, external_views) require **Airflow 3.1+**. **NEVER import `flask`, `flask_appbuilder`, or use `appbuilder_views` / `flask_blueprints`** — these are Airflow 2 patterns and will not work in Airflow 3. If existing code uses them, rewrite the entire registration block using FastAPI. |
| 6 | > |
| 7 | > **Security**: FastAPI plugin endpoints are **not automatically protected** by Airflow auth. If your endpoints need to be private, implement authentication explicitly using FastAPI's security utilities. |
| 8 | > |
| 9 | > **Restart required**: Changes to Python plugin files require restarting the API server. Static file changes (HTML, JS, CSS) are picked up immediately. Set `AIRFLOW__CORE__LAZY_LOAD_PLUGINS=False` during development to load plugins at startup rather than lazily. |
| 10 | > |
| 11 | > **Relative paths always**: In `external_views`, `href` must have no leading slash. In HTML and JavaScript, use relative paths for all assets and `fetch()` calls. Absolute paths break behind reverse proxies. |
| 12 | |
| 13 | ### Before writing any code, verify |
| 14 | |
| 15 | 1. Am I using `fastapi_apps` / FastAPI — not `appbuilder_views` / Flask? |
| 16 | 2. Are all HTML/JS asset paths and `fetch()` calls relative (no leading slash)? |
| 17 | 3. Are all synchronous SDK or SQLAlchemy calls wrapped in `asyncio.to_thread()`? |
| 18 | 4. Do the `static/` and `assets/` directories exist before the FastAPI app mounts them? |
| 19 | 5. If the endpoint must be private, did I add explicit FastAPI authentication? |
| 20 | |
| 21 | --- |
| 22 | |
| 23 | ## Step 1: Choose plugin components |
| 24 | |
| 25 | A single plugin class can register multiple component types at once. |
| 26 | |
| 27 | | Component | What it does | Field | |
| 28 | |-----------|-------------|-------| |
| 29 | | Custom API endpoints | FastAPI app mounted in Airflow process | `fastapi_apps` | |
| 30 | | Nav / page link | Embeds a URL as an iframe or links out | `external_views` | |
| 31 | | React component | Custom React app embedded in Airflow UI | `react_apps` | |
| 32 | | API middleware | Intercepts all Airflow API requests/responses | `fastapi_root_middlewares` | |
| 33 | | Jinja macros | Reusable Python functions in DAG templates | `macros` | |
| 34 | | Task instance button | Extra link button in task Detail view | `operator_extra_links` / `global_operator_extra_links` | |
| 35 | | Custom timetable | Custom scheduling logic | `timetables` | |
| 36 | | Event hooks | Listener callbacks for Airflow events | `listeners` | |
| 37 | |
| 38 | --- |
| 39 | |
| 40 | ## Step 2: Plugin registration skeleton |
| 41 | |
| 42 | ### Project file structure |
| 43 | |
| 44 | Give each plugin its own subdirectory under `plugins/` — this keeps the Python file, static assets, and templates together and makes multi-plugin projects manageable: |
| 45 | |
| 46 | ``` |
| 47 | plugins/ |
| 48 | my-plugin/ |
| 49 | plugin.py # AirflowPlugin subclass — auto-discovered by Airflow |
| 50 | static/ |
| 51 | index.html |
| 52 | app.js |
| 53 | assets/ |
| 54 | icon.svg |
| 55 | ``` |
| 56 | |
| 57 | `BASE_DIR = Path(__file__).parent` in `plugin.py` resolves to `plugins/my-plugin/` — static and asset paths will be correct relative to that. Create the subdirectory and any static/assets folders before starting Airflow, or `StaticFiles` will raise on import. |
| 58 | |
| 59 | ```python |
| 60 | from pathlib import Path |
| 61 | from airflow.plugins_manager import AirflowPlugin |
| 62 | from fastapi import FastAPI |
| 63 | from fastapi.staticfiles import StaticFiles |
| 64 | from fastapi.responses import FileResponse |
| 65 | |
| 66 | BASE_DIR = Path(__file__).parent |
| 67 | |
| 68 | app = FastAPI(title="My Plugin") |
| 69 | |
| 70 | # Both directories must exist before Airflow starts or FastAPI raises on import |
| 71 | app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static") |
| 72 | app.mount("/assets", StaticFiles(directory=BASE_DIR / "assets"), name="assets") |
| 73 | |
| 74 | |
| 75 | class MyPlugin(AirflowPlugin): |
| 76 | name = "my_plugin" |
| 77 | |
| 78 | fastapi_apps = [ |
| 79 | { |
| 80 | "app": app, |
| 81 | "url_prefix": "/my-plugin", # plugin available at {AIRFLOW_HOST}/my-plugin/ |
| 82 | "name": "My Plugin", |
| 83 | } |
| 84 | ] |
| 85 | |
| 86 | external_views = [ |
| 87 | { |
| 88 | "name": "My Plugin", |
| 89 | "href": "my-plugin/ui", # NO leading slash — breaks on Astro and reverse proxies |
| 90 | "destination": "nav", # see locations table below |
| 91 | "category": "browse", # nav bar category (nav destination |