Skip to main content

Capability — routing a request to a specialised plugin

capability — several plugins of the kind, each handling its own subset of inputs; exactly one plugin receives the request. Typical uses: file processors for different formats, language-specific parsers, specialised handlers with a fallback.

Scenario: a semantic chunker with fallback

The application has a code indexer; it must split files into semantically meaningful fragments. For Python / TypeScript / Go we use a Tree-sitter-based chunker; for the remaining formats — a simple length-based fallback.

The kind's hookspec

kinds/chunker/v1.yaml
kind: chunker
kind_api_version: 1.0.0
description: Splitting text into fragments.

hooks:
- name: chunk
dispatch: capability
description: Split the text.
input_schema: schemas/chunk_input.json
output_schema: schemas/chunk_output.json
mcp_exposed: true

The hookspec declares the capability dispatcher, but how to route (which input fields → which supports_*) is decided by the application via a capability matcher; the details live in _meta/capability_matchers.yaml or in the consumer application.

Specialised plugins

plugins/chunker/tree-sitter/dagstack.toml
[plugin]
name = "tree-sitter"
kind = "chunker"
runtime = "in_process"
core_version = ">=0.1.0,<1.0.0"
priority = 50
supports_languages = ["python", "typescript", "javascript", "go", "java", "ruby"]
supports_extensions = [".py", ".ts", ".js", ".go", ".java", ".rb"]
plugins/chunker/tree-sitter/plugin.py
from tree_sitter import Parser

SUPPORTED_LANGUAGES = {"python", "typescript", "javascript", "go", "java", "ruby"}


class TreeSitterChunker:
async def setup(self, ctx):
self._parsers = {
"python": Parser(PYTHON_LANG),
"typescript": Parser(TS_LANG),
# ...
}

def matches(self, payload: dict) -> bool:
# CapabilityDispatcher consults this method to decide whether the plugin
# can handle the given input.
return payload.get("language") in SUPPORTED_LANGUAGES

def chunk(self, ctx, payload: dict) -> dict:
parser = self._parsers[payload["language"]]
tree = parser.parse(payload["content"].encode())
return {
"chunks": [
{"text": node.text.decode(), "line_start": node.start_point[0]}
for node in _walk_top_level_nodes(tree.root_node)
],
}
plugins/chunker/fixed/dagstack.toml
[plugin]
name = "fixed"
kind = "chunker"
runtime = "in_process"
core_version = ">=0.1.0,<1.0.0"
priority = 10
fallback = true
plugins/chunker/fixed/plugin.py
class FixedChunker:
"""Fallback: splits arbitrary text into fixed-length pieces."""

async def setup(self, ctx):
pass

def matches(self, payload: dict) -> bool:
# The fallback declares it can handle anything; capability matchers
# will still pick a more specific plugin first thanks to priority.
return True

def chunk(self, ctx, payload: dict) -> dict:
text = payload["content"]
size = 512
chunks = [
{"text": text[i:i + size], "offset": i}
for i in range(0, len(text), size)
]
return {"chunks": chunks}

Calling the dispatcher

from dagstack.plugin_system import CapabilityDispatcher

dispatcher = CapabilityDispatcher(registry)

# Python file → tree-sitter (matches() returns True for language="python")
plugin = dispatcher.dispatch(
"chunker", "chunk", ctx,
payload={"language": "python", "content": "def hello(): ..."},
)
result = plugin.chunk(ctx, {"language": "python", "content": "def hello(): ..."})

# Markdown file → fixed (only the fallback's matches() returns True)
plugin = dispatcher.dispatch(
"chunker", "chunk", ctx,
payload={"extension": ".md", "content": "# Hello\n..."},
)
result = plugin.chunk(ctx, {"extension": ".md", "content": "# Hello\n..."})

The CapabilityDispatcher.dispatch() method returns the selected plugin instance; the caller invokes the kind-specific hook on it. There is no built-in "execute and return result" convenience.

The plugin selection algorithm

dispatch(kind, input) → plugin
1. Filter plugins of the kind whose supports_* match the input fields.
2. If there are no candidates → the plugin with fallback = true.
If there is no fallback either → DispatchError (HTTP 422 equivalent).
3. If there is more than one candidate → priority desc, ties by name.
4. Return the first one.

The plugin author only declares supports_* in the manifest. The core builds the capability → plugins index once at startup — picking a plugin for a request is an O(1) lookup.

Fallback plugin contract

The fallback must handle any valid input of the kind — without raising. All edge cases (empty input, broken encoding, binary data, very large size) return a correct result (possibly empty or a skip signal), not an unhandled exception.

The base contract test framework checks this automatically — running the fallback through a curated set of edge-case inputs. If any one call yields an unhandled exception, the test fails.

Exactly one plugin of the kind may have fallback = true. Two fallbacks → AmbiguousPlugin at startup, the core does not start.

Extending without a breaking change

Adding a new specialised plugin (for example, rust-chunker with supports_languages = ["rust"]):

  1. Create the folder, dagstack.toml, plugin.py.
  2. The manifest with supports_languages = ["rust"].
  3. No changes in the existing plugins, in the fallback, or in the code that calls the dispatcher.

Rust files that previously fell through to the fixed fallback now go to rust-chunker.

Governance filtering

Via the horizontal extensions mechanism (see ADR-0005 §4), the host can constrain the set of permitted plugins for a particular tenant:

governance policy:
tenant="acme-healthcare" allowed_plugins where capability ⊇ {"pii_handling"}

For that tenant the dispatcher narrows the candidates before capability matching — to plugins that declared the pii_handling capability only.

Common errors

SymptomCause
DispatchError: no handler for inputNo plugin has a matching capability and there is no fallback. Add a plugin or set fallback = true on one of the existing ones.
AmbiguousPlugin: two plugins with fallback = trueTwo plugins of the kind have fallback = true. Resolve — keep exactly one fallback.
Fallback is picked even though a specialised plugin existsCheck supports_* in the specialised plugin's manifest; there may be a typo in the language or extension name.
Fallback fails on a particular inputThe fallback contract is broken; fix the edge-case handling and re-check through the contract suite.

When capability is the wrong fit

  • You need every plugin at once — use broadcast-collect.
  • Implementations replace one another, they do not specialise — use singleton.
  • You need sequential processing — use chain.

See also