Add your own actions to Dags, Dag Runs and Task Instances

Airflow renders a fixed set of actions for each entity — pause, trigger, clear, mark as, delete. AirflowPlugin.ui_actions lets you add your own beside them, everywhere Airflow already offers actions for that entity:

  • the trailing cell of its table rows,

  • its card, on lists that have a card view, and

  • the action row of its header card on the detail page.

An action either opens a URL or mounts a React component of your own. Unlike external_views and react_apps, which each take over a whole tab, an action is a single button — the right size for “open the runbook for this Dag” or “show me this task’s payload”.

Your first action

Drop this in a file under your plugins folder:

from airflow.plugins_manager import AirflowPlugin


class RunbookPlugin(AirflowPlugin):
    name = "runbook_plugin"

    ui_actions = [
        {
            "action_type": "link",
            "name": "Open runbook",
            "destination": "dag",
            "href": "https://runbooks.example.com/{DAG_ID}",
        }
    ]

Restart the API server. Every Dag now carries an “Open runbook” button — on each row and card of the Dags list, and in the header of each Dag’s detail page — pointing at that Dag’s runbook.

Four keys are required: action_type, name, destination, and the target the kind needs (href for link, bundle_url for react). name is the button’s tooltip and its accessible label, so write it as a label rather than an identifier.

destination picks the entity, and with it the surfaces the button appears on:

destination

Appears on

Template variables

dag

Dags list rows and cards, Dag detail header

{DAG_ID}

dag_run

Dag Runs rows, Dag Run detail header

{DAG_ID}, {RUN_ID}

task_instance

Task Instances rows, Task Instance detail header

{DAG_ID}, {RUN_ID}, {TASK_ID}, {MAP_INDEX}, {REGION_ID}, {REGION_INDEX}

A variable with no value on the current page is left in the URL as-is rather than replaced with an empty string, so a half-resolved link fails visibly instead of quietly pointing somewhere else. Values are URL-encoded for you.

{REGION_ID} and {REGION_INDEX} identify one iteration of a looping task, and resolve only on rows that have them; on every other task instance they are left in the URL like any other variable with no value.

Limiting an action to the Dags that need it

An action with no scoping appears on every entity of its kind, which gets noisy fast. The optional applies_to block is the same one external views and React apps use:

{
    "action_type": "link",
    "name": "Open runbook",
    "destination": "dag",
    "href": "https://runbooks.example.com/{DAG_ID}",
    "applies_to": {"dag.tags.name": ["production"]},
}

Keys are dotted field paths into the records the surface has, so anything the REST API returns for the entity is addressable. The case this is most often wanted for is state:

{
    "action_type": "link",
    "name": "Investigate failure",
    "destination": "task_instance",
    "href": "https://ai.example.com/investigate?dag={DAG_ID}&task={TASK_ID}&run={RUN_ID}",
    # Offered on failed task instances and nowhere else.
    "applies_to": {"state": ["failed", "upstream_failed"]},
}

Values are OR-ed within a path and AND-ed across paths, and a path whose root record the current surface cannot resolve is skipped rather than failing the match. See Scoping a view to specific Dags and tasks for the full semantics.

One consequence is worth knowing before you rely on it. On the deployment-wide Dag Runs and Task Instances lists, each row is its own record but Airflow has not loaded the parent Dag, so a dag.* path cannot be judged there and is skipped — the action shows on every row. Paths rooted at the row itself, like state or dag_id, are evaluated normally, so pair them if that matters:

"applies_to": {"dag.tags.name": ["production"], "dag_id": ["checkout_etl"]}

Everywhere else — the Dags list and all three header cards — the Dag is available.

Actions that open a surface

Warning

React plugins are experimental. The feature may change in future versions without warning based on user feedback and errors reported.

This is an experimental feature.

A react action mounts a component you supply. Airflow renders only the button — your component renders its own surface, so it can be a popover, a modal, a side drawer or anything else, and it owns its own appearance and accessibility.

{
    "action_type": "react",
    "name": "Inspect payload",
    "destination": "task_instance",
    "bundle_url": "https://example.com/static/js/inspect_payload.js",
}

Alongside the entity context props — the same ones documented in React app context props — your component receives three props for driving its surface:

open

Whether the button is currently toggled on. Your component is mounted the first time the button is clicked and stays mounted afterwards, so it can animate out and keep its state; follow open rather than assuming you are unmounted when closed.

onClose

Call it to toggle the button back off — after submitting, or when your surface is dismissed by a backdrop click or Escape.

triggerRef

A ref to the button element. A popover needs it to anchor against, since the button belongs to Airflow rather than to you.

Note the context comes from the entity whose button was clicked, not from the page’s URL. A row’s action lives on a list page whose URL says nothing about that row, so read dagId, runId and taskId from props rather than from the router.

A minimal component, with no build step, borrowing React and Chakra from the host — enough to see the contract working before you reach for tooling:

const React = globalThis.React;
const { Popover, Portal, Text } = globalThis.ChakraUI;
const h = React.createElement;

globalThis["Inspect payload"] = ({ onClose, open, taskId, triggerRef }) =>
  h(
    Popover.Root,
    {
      onOpenChange: (event) => { if (!event.open) onClose(); },
      open: Boolean(open),
      positioning: {
        getAnchorRect: () => triggerRef?.current?.getBoundingClientRect() ?? null,
        placement: "bottom-end",
      },
    },
    h(Portal, null,
      h(Popover.Positioner, null,
        h(Popover.Content, null,
          h(Popover.Body, null, h(Text, null, `Task: ${taskId}`)),
        ),
      ),
    ),
  );

The bundle must register a function component on globalThis under the action’s name. Airflow also accepts the shared globalThis.AirflowPlugin global, which is what the bootstrap tool below produces.

For anything beyond a demo, use the React plugin bootstrap tool described in Customize view of Apache from Airflow web UI — it generates a Vite project with the bundling configuration Airflow needs, and shares React with the host rather than shipping its own copy.

Two rules for React actions

Give every React action a distinct name. The component is registered on the page under its name, so two React actions sharing one — or a React action sharing a name with a react_apps entry — would render each other’s component. Airflow warns about this at load time and skips the action rather than letting it happen. A react_apps entry always keeps the name; between two actions, the plugin that loads first keeps it. The name also cannot be blank or AirflowPlugin, which is the shared slot Airflow clears before each bundle loads.

Warning

Do not name a React action after a browser global. The component is looked up at globalThis[name], and the browser has already put plenty of ordinary words there – Comment, File, History, Image, Notification, Plugin, Request and Text among them. Because name is also the button’s label, these are exactly the words a label reaches for.

The lookup finds the browser’s constructor before your bundle’s registration, so the button does nothing and the reason only appears in the console. A bundle that registers under that name instead replaces the browser’s global for the whole page, which can break Airflow itself. Airflow cannot reject these for you: the set differs by browser and version. Prefix the name, or make it a phrase – "Inspect payload" rather than "Image".

react_apps are looked up the same way and have the same hazard.

Give every React action its own bundle_url. Airflow clears the shared global before each load, and a browser will not re-execute a module it has already imported, so a second action pointing at a bundle that is already loaded finds nothing registered under its name. If you genuinely want one bundle to back several actions, register each name explicitly from it.

Serving a bundle from your own plugin

A bundle_url can point anywhere the browser can reach, including a CDN. To serve it from Airflow itself, mount a FastAPI app in the same plugin:

from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles

from airflow.plugins_manager import AirflowPlugin

app = FastAPI()
app.mount("/static", StaticFiles(directory="/path/to/dist"), name="inspect_static")


class InspectPlugin(AirflowPlugin):
    name = "inspect_plugin"

    fastapi_apps = [{"name": "Inspect", "app": app, "url_prefix": "/inspect"}]

    ui_actions = [
        {
            "action_type": "react",
            "name": "Inspect payload",
            "destination": "task_instance",
            "bundle_url": "inspect/static/main.umd.cjs",
        }
    ]

A bundle_url is resolved the same way a link’s href is, so write it relative – inspect/static/main.umd.cjs – and it loads whether Airflow is served from the root or from under a prefix. A leading slash ties the plugin to a root deployment.

When an action does not appear

Airflow skips an action it cannot render and logs why, rather than failing the plugin. Check the API server log at startup for a line naming your plugin. An action is skipped when it:

  • is not a dictionary;

  • names an unknown action_type or destination;

  • omits the href or bundle_url its kind requires, or has an empty name;

  • is a react action whose name collides with another React component.

applies_to is reported separately, and what happens depends on what is wrong with it. A malformed block — not a dictionary, a non-string path, a value that is not a list of strings — is stripped, and the action loads unscoped. A path the destination cannot evaluate is warned about and skipped, which is what lets one block be shared across destinations. But a path naming a field no record has is a misconfiguration rather than a filter that never matches, so the action is not loaded at all and the reason is logged as an error: it could never scope the way it asks to, and showing it everywhere would be the opposite of the intent. See Plugins for the path syntax.

The rest of the plugin is unaffected by any of this: one bad action does not cost you the plugin’s other actions, its views, or its macros.

Security

Note

applies_to decides whether a button is drawn, not what its target will do. Anyone who can read GET /plugins sees every action’s href and bundle_url and can request them directly, for any entity — including ones the scoping would have hidden the button for. A link action’s target must authenticate and authorize every request itself, and validate the identifiers it is handed rather than trusting them.

See also

Was this entry helpful?