Skip to main content

Dispatch

When an application makes a call through plugin-system — for example, "find documents relevant to this query" — which plugin actually handles the call? The answer depends on the dispatch class of the plugin kind.

Plugin-system fixes five classes — normatively, as a closed enumeration (see ADR-0002 for the formal contract). Each plugin kind declares one of the five classes for every hook in its hookspec.

Overview of the five classes​

ClassPlugins calledReturnsSemantics
Singletononethe plugin's result"pick the active one"
Broadcast-collectallarray of results"collect everything"
Broadcast-notifyallnothing (void)"let everyone know"
Chaina chain in orderresult of the last"middleware"
Capabilityone (matching)the plugin's result"route by input type"

Singleton​

One active plugin handles every call to the kind. Used for:

  • Backend connectors — exactly one LLM provider, exactly one vector store.
  • Orchestrators — one orchestrator per runtime.
  • Any kind for which "several active" makes no sense.

Algorithm for picking the active plugin:

  1. An explicit application routing policy (per-tenant, blue/green) — if one is supplied.
  2. Override via the env var DAGSTACK_ACTIVE_<KIND>=<plugin_name>.
  3. Sort candidates by priority desc from the manifest; pick the highest.
  4. On a tie in priority with no override → AmbiguousPlugin, the core does not start.
# Get the active plugin (singleton kinds resolve a single instance):
llm = registry.get_plugin("llm")
response = llm.complete(prompt="Hello")

# Or pick by an explicit name (when two LLMs are registered):
llm = registry.get_plugin("llm", name="openai_compatible")

Broadcast-collect​

Every registered plugin of the kind is called and the results are collected into an array. Used for:

  • Tool catalogues (each plugin returns its own list of tools) — uniform aggregation.
  • Metric exporters (each plugin emits its own metrics).
  • Capability providers (plugins declare what they can do — the collector gathers it).

Order: by priority desc, ties broken by name. Error policy: fail-fast by default (a single plugin's failure aborts the collect) — overridable to best_effort in the kind's hookspec metadata.

from dagstack.plugin_system import BroadcastCollectDispatcher

dispatcher = BroadcastCollectDispatcher(registry)
results, errors = dispatcher.dispatch(
"metric_exporter",
"on_event",
ctx,
event="request_finished",
duration_ms=42,
)
# results = [report_from_prometheus, report_from_statsd, ...]
if errors:
for plugin_name, original in errors.errors:
ctx.logger.warning("plugin %s failed: %s", plugin_name, original)

Broadcast-notify​

Fire-and-forget — every plugin is notified sequentially in priority desc order; return values are not collected, and a single plugin's failure is logged but not propagated. Used for:

  • Lifecycle events (on_started, on_request, on_error).
  • Telemetry events without aggregation.
  • Audit hooks.
from dagstack.plugin_system import BroadcastNotifyDispatcher

notifier = BroadcastNotifyDispatcher(registry)
notifier.dispatch(
"event_listener",
"on_event",
ctx,
event_type="user_logged_in",
user_id="acme-42",
)
# No return value; plugins are called sequentially, exceptions are logged.

Chain​

A sequential chain: output[N] becomes input[N+1]. Strict order by priority desc. Used for:

  • Middleware — query rewriting, post-processing, result re-ranking.
  • Pipeline steps that transform data.
  • Horizontal middleware (governance, quota — see ADR-0005).

Aborting the chain — return an explicit sentinel value (STOP_CHAIN in Python) or raise an exception; subsequent plugins are not invoked.

from dagstack.plugin_system import ChainDispatcher, STOP_CHAIN

chain = ChainDispatcher(registry)
# Each plugin receives the previous plugin's output via `initial_value=`:
rewritten = chain.dispatch(
"query_rewriter",
"rewrite",
ctx,
initial_value="how does dagstack work",
)
# query_rewriter #1 → normaliser → query_rewriter #2 → synonymiser → ...
# A plugin returning STOP_CHAIN halts the chain and that value is returned.

Important: the priority >= 1000 range is reserved for horizontal middleware (governance, quota, observability) — see ADR-0005. Business plugins use priority < 1000.

Capability​

Routing by input type — several plugins of the kind, each handling its own subset of inputs. Exactly one plugin receives the request (chosen by the manifest's declared supports_* fields).

Scenario: a file indexer​

A consumer application has a code indexer that walks a repository and needs a specialised handler per file type: an AST parser for Python files, a heading extractor for Markdown, a schema detector for JSON, a hash tagger for binary artefacts. All of them are plugins of the same kind file_indexer, dispatched by capability.

Each specialised plugin declares what it handles:

plugins/python-indexer/dagstack.toml
[plugin]
name = "python-indexer"
kind = "file_indexer"
runtime = "in_process"
core_version = ">=0.1.0,<1.0.0"
supports_extensions = [".py"]
priority = 50
plugins/markdown-indexer/dagstack.toml
[plugin]
name = "markdown-indexer"
kind = "file_indexer"
runtime = "in_process"
core_version = ">=0.1.0,<1.0.0"
supports_extensions = [".md", ".mdx"]
priority = 50
plugins/binary-hasher/dagstack.toml
[plugin]
name = "binary-hasher"
kind = "file_indexer"
runtime = "in_process"
core_version = ">=0.1.0,<1.0.0"
fallback = true # picks up whatever the others did not recognise
priority = 0

In the indexer's code — one dispatcher call per file:

from dagstack.plugin_system import CapabilityDispatcher

capability = CapabilityDispatcher(registry)

for file_path in repo_files:
indexer = capability.dispatch(
"file_indexer",
"index",
ctx,
payload={"extension": file_path.suffix, "path": str(file_path)},
)
index_entry = indexer.index(file_path.read_bytes())

Adding a new file type (.go, .rs) = a new plugin with supports_extensions = [".go"]. The indexer code is untouched; binary-hasher will not receive the file because the new plugin advertises a more specific capability. If an unknown extension shows up, binary-hasher (fallback) still handles it correctly.

Fallback plugin — exactly one plugin per kind may declare fallback = true. Contract: the fallback must handle any valid input without raising.

How to choose a dispatch class for a new kind​

ScenarioClass
One active per kind (LLM, vector store, orchestrator)singleton
Collect a list/catalogue from everyone (tools, metrics, capabilities)broadcast_collect
Event with N independent subscribers (lifecycle, telemetry)broadcast_notify
Middleware that transforms data (rewrite, post-process)chain
Implementations specialised by input type (ext/lang/mime)capability

When the class cannot be changed​

A dispatch class is fixed in the kind's hookspec and effectively baked into the contract. Changing the class = breaking change (a major bump of kind_api_version) — every plugin of the kind must be rewritten against the new semantics.

When designing a new kind, choose the class carefully: if you start with singleton and later add alternative implementations for different inputs, switching to capability will require migration. An alternative: design for capability from the start with a single implementation that declares fallback = true and supports_* = all cases — a future move adds specialised plugins without a breaking change.

See also​