astronomer/agents/airflow-plugins

Claude Code and agent skill by astronomer/agents

Trust tier 2
  • community
Source check pending

Checking that the SKILL.md file is still in the repository.

Builds 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 Airflow plugin, adding a custom UI page or nav entry, building FastAPI-backed endpoints inside Airflow, serving static assets from a plugin, embedding a React app, adding middleware to the API server, creating custom operator extra links, or calling the Airflow REST API from inside a plugin; also when AirflowPlugin, fastapi_apps, external_views, react_apps, or plugin registration come up.

Install

npx vskill@latest install astronomer/agents/airflow-plugins

SKILL.md

Airflow 3 Plugins

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.

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.
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.
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.
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.
Before writing any code, verify
  1. Am I using fastapi_apps / FastAPI — not appbuilder_views / Flask?
  2. Are all HTML/JS asset paths and fetch() calls relative (no leading slash)?
  3. Are all synchronous SDK or SQLAlchemy calls wrapped in asyncio.to_thread()?
  4. Do the static/ and assets/ directories exist before the FastAPI app mounts them?
  5. If the endpoint must be private, did I add explicit FastAPI authentication?

Step 1: Choose plugin components

A single plugin class can register multiple component types at once.

ComponentWhat it doesField
Custom API endpointsFastAPI app mounted in Airflow processfastapi_apps
Nav / page linkEmbeds a URL as an iframe or links outexternal_views
React componentCustom React app embedded in Airflow UIreact_apps
API middlewareIntercepts all Airflow API requests/responsesfastapi_root_middlewares
Jinja macrosReusable Python functions in DAG templatesmacros
Task instance buttonExtra link button in task Detail viewoperator_extra_links / global_operator_extra_links
Custom timetableCustom scheduling logictimetables
Event hooksListener callbacks for Airflow eventslisteners

Step 2: Plugin registration skeleton

Project file structure

Give each plugin its own subdirectory under plugins/ — this keeps the Python file, static assets, and templates together and makes multi-plugin projects manageable:

plugins/
my-plugin/
plugin.py # AirflowPlugin subclass — auto-discovered by Airflow
static/
index.html
app.js
assets/
icon.svg

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.

python
from pathlib import Path
from airflow.plugins_manager import AirflowPlugin
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
BASE_DIR = Path(__file__).parent
app = FastAPI(title="My Plugin")
# Both directories must exist before Airflow starts or FastAPI raises on import
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
app.mount("/assets", StaticFiles(directory=BASE_DIR / "assets"), name="assets")
class MyPlugin(AirflowPlugin):
name = "my_plugin"
fastapi_apps = [
{
"app": app,
"url_prefix": "/my-plugin", # plugin available at {AIRFLOW_HOST}/my-plugin/
"name": "My Plugin",
}
]
external_views = [
{
"name": "My Plugin",
"href": "my-plugin/ui", # NO leading slash — breaks on Astro and reverse proxies
"destination": "nav", # see locations table below
"category": "browse", # nav bar category (nav destination only)
"url_route": "my-plugin", # unique route name (required for React apps)
"icon": "/my-plugin/static/icon.svg" # DOES use a leading slash — served by FastAPI
}
]
External view locations
destinationWhere it appears
"nav"Left navigation bar (also set category)
"dag"Extra tab on every Dag page
"dag_run"Extra tab on every Dag run page
"task"Extra tab on every task page
"task_instance"Extra tab on every task instance page
Nav bar categories (destination: "nav")

Set "category" to place the link under a specific nav group: "browse", "admin", or omit for top-level.

External URLs and minimal plugins

href can be a relative path to an internal endpoint ("my-plugin/ui") or a full external URL. A plugin with only external_views and no fastapi_apps is valid — no backend needed for a simple link or tab:

python
from airflow.plugins_manager import AirflowPlugin
class LearnViewPlugin(AirflowPlugin):
name = "learn_view_plugin"
external_views = [
{
"name": "Learn Airflow 3",
"href": "https://www.astronomer.io/docs/learn",
"destination": "dag", # adds a tab to every Dag page
"url_route": "learn"
}
]

The no-leading-slash rule applies to internal paths only — full https:// URLs are fine.


Step 3: Serve the UI entry point

python
@app.get("/ui", response_class=FileResponse)
async def serve_ui():
return FileResponse(BASE_DIR / "static" / "index.html")

In HTML, always use relative paths. Absolute paths break when Airflow is mounted at a sub-path:

html
<!-- correct -->
<link rel="stylesheet" href="static/app.css" />
<script src="static/app.js?v=20240315"></script>
<!-- breaks behind a reverse proxy -->
<script src="/my-plugin/static/app.js"></script>

Same rule in JavaScript:

javascript
fetch('api/dags') // correct — relative to current page
fetch('/my-plugin/api/dags') // breaks on Astro and sub-path deploys

Step 4: Call the Airflow API from your plugin

Only needed if your plugin calls the Airflow REST API. Plugins that only serve static files, register external_views, or use direct DB access do not need this step — skip to Step 5 or Step 6.
Add the dependency

Only if REST API communication is being implemented: add apache-airflow-client to the project's dependencies. Check which file exists and act accordingly:

File foundAction
requirements.txtAppend apache-airflow-client
pyproject.toml (uv / poetry)uv add apache-airflow-client or poetry add apache-airflow-client
None of the aboveTell the user: "Add apache-airflow-client to your dependencies before running the plugin."

Use apache-airflow-client to talk to Airflow's own REST API. The SDK is synchronous but FastAPI routes are async — never call blocking SDK methods directly inside async def or you will stall the event loop and freeze all concurrent requests.

JWT token management

Cache one token per process. Refresh 5 minutes before the 1-hour expiry. Use double-checked locking so multiple concurrent requests don't all race to refresh simultaneously:

Replace MYPLUGIN_ with a short uppercase prefix derived from the plugin name (e.g. if the plugin is called "Trip Analyzer", use TRIP_ANALYZER_). If no plugin name has been given yet, ask the user before writing env var names.
python
import asyncio
import os
import threading
import time
import airflow_client.client as airflow_sdk
import requests
AIRFLOW_HOST = os.environ.get("MYPLUGIN_HOST", "http://localhost:8080")
AIRFLOW_USER = os.environ.get("MYPLUGIN_USERNAME", "admin")
AIRFLOW_PASS = os.environ.get("MYPLUGIN_PASSWORD", "admin")
AIRFLOW_TOKEN = os.environ.get("MYPLUGIN_TOKEN") # Astronomer Astro: Deployment API token
_cached_token: str | None = None
_token_expires_at: float = 0.0
_token_lock = threading.Lock()
def _fetch_fresh_token() -> str:
"""Exchange username/password for a JWT via Airflow's auth endpoint."""
response = requests.post(
f"{AIRFLOW_HOST}/auth/token",
json={"username": AIRFLOW_USER, "password": AIRFLOW_PASS},
timeout=10,
)
response.raise_for_status()
return response.json()["access_token"]
def _get_token() -> str:
# Astronomer Astro production: use static Deployment API token directly
if AIRFLOW_TOKEN:
return AIRFLOW_TOKEN
global _cached_token, _token_expires_at
now = time.monotonic()
# Fast path — no lock if still valid
if _cached_token and now < _token_expires_at:
return _cached_token
# Slow path — one thread refreshes, others wait
with _token_lock:
if _cached_token and now < _token_expires_at:
return _cached_token
_cached_token = _fetch_fresh_token()
_token_expires_at = now + 55 * 60 # refresh 5 min before 1-hour expiry
return _cached_token
def _make_config() -> airflow_sdk.Configuration:
config = airflow_sdk.Configuration(host=AIRFLOW_HOST)
config.access_token = _get_token()
return config

After implementing auth, tell the user:

  • Local development: set MYPLUGIN_USERNAME and MYPLUGIN_PASSWORD in .env — JWT exchange happens automatically.
  • Astronomer Astro (production): create a Deployment API token and set it as MYPLUGIN_TOKEN — the JWT exchange is skipped entirely:
  1. Astro UI → open the Deployment → Access → API Tokens → + Deployment API Token
  2. Copy the token value (shown only once)
  3. astro deployment variable create MYPLUGIN_TOKEN=<token>

MYPLUGIN_USERNAME and MYPLUGIN_PASSWORD are not needed on Astro.

Wrapping SDK calls with asyncio.to_thread
python
from fastapi import HTTPException
from airflow_client.client.api import DAGApi
@app.get("/api/dags")
async def list_dags():
try:
def _fetch():
with airflow_sdk.ApiClient(_make_config()) as client:
return DAGApi(client).get_dags(limit=100).dags
dags = await asyncio.to_thread(_fetch)
return [{"dag_id": d.dag_id, "is_paused": d.is_paused, "timetable_summary": d.timetable_summary} for d in dags]
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
API field names: Never guess response field names — verify against the REST API reference. Key DAGResponse fields: dag_id, dag_display_name, description, is_paused, timetable_summary, timetable_description, fileloc, owners, tags.

The pattern is always: define a plain inner def _fetch() with all SDK logic, then await asyncio.to_thread(_fetch).

Alternative: Direct database access
Warning — use with caution and tell the user. The Airflow metadb is not a public interface. Direct writes or poorly-formed queries can corrupt scheduler state. Whenever you use this pattern, explicitly tell the user: "This accesses Airflow's internal database directly. The internal models are not part of the public API, can change between Airflow versions, and incorrect queries can cause issues in the metadb. Prefer apache-airflow-client unless the operation is not exposed via the REST API."

This is the start of the file. Read the full SKILL.md on GitHub.

Security

  • Security scanPassed, 100 of 100Pattern checks on the skill's files.
  • Model reviewPassed

Quality evaluation

  • Task evaluationNot runNo evaluation yet. Run one in Skill Studio.

Source

Popularity

Repository stars
412
Repository forks
56
7-day trend score
30

Stars and forks are for the whole GitHub repository, not this skill alone. Refreshed 19 Mar 2026, 7 months ago.

Works with

Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.

  • Claude Code
  • Cursor
  • GitHub Copilot
  • Windsurf
  • Codex
  • Gemini CLI
  • Cline
  • Amp
  • Roo Code
  • Goose
  • Continue
  • Kilo Code
  • Junie
  • Trae
  • OpenHands
  • Qwen Code
  • AI Agent
  • Tabnine Chat
  • Sourcegraph Cody
  • Amazon Q
  • Firebase Genkit
  • Sweep
  • Devin
  • Phind
  • Mentat
  • Claude Desktop
  • Aider
  • GPT Pilot
  • Plandex
  • bolt.new
  • v0
  • Replit Agent
  • Anse
  • TypingMind
  • BetterChatGPT
  • LibreChat
  • Warp AI
  • Pieces
  • Fig AI
Embed badge
vskill VERIFIED badge
[![vskill VERIFIED](https://verified-skill.com/api/v1/skills/astronomer/agents/airflow-plugins/badge)](https://verified-skill.com/skills/astronomer/agents/airflow-plugins)

Something wrong with this skill or its page? Tell us.