Skip to main content

Singleton — one active plugin per kind

singleton is the simplest class: one active plugin handles every call to the kind. Used for kinds where "several active" makes no sense: one active payment provider per application, one vector store, one orchestrator, one LLM backend.

Step 1. Declare the kind

kinds/payment_provider/v1.yaml
kind: payment_provider
kind_api_version: 1.0.0
description: Payment provider — charge, refund, status check.

hooks:
- name: charge
dispatch: singleton
input_schema: schemas/charge.input.json
output_schema: schemas/charge.output.json
mcp_exposed: true
- name: refund
dispatch: singleton
input_schema: schemas/refund.input.json
output_schema: schemas/refund.output.json
mcp_exposed: true

Step 2. Write two implementations

plugins/payment_provider/stripe/dagstack.toml
[plugin]
name = "stripe"
kind = "payment_provider"
runtime = "in_process"
core_version = ">=0.1.0,<1.0.0"
priority = 50
plugins/payment_provider/stripe/plugin.py
class StripeProvider:
async def setup(self, ctx):
self._http = ctx.registry.resource_registry.get("http_client")
self._config = ctx.config

async def charge(self, input):
response = await self._http.post(
"https://api.stripe.com/v1/charges",
headers={"Authorization": f"Bearer {self._config['api_key']}"},
json={"amount": input.amount_cents, "currency": input.currency, "source": input.source},
)
return {"transaction_id": response.json()["id"], "status": "succeeded"}
plugins/payment_provider/internal/dagstack.toml
[plugin]
name = "internal"
kind = "payment_provider"
runtime = "in_process"
core_version = ">=0.1.0,<1.0.0"
priority = 40

The registry now holds two plugins of the kind payment_provider at different priorities.

Step 3. Picking the active plugin

Per ADR-0002 §1, the algorithm is:

  1. Application routing policy (when supplied) — for example, per-tenant selection: premium tenants use Stripe, internal tenants use the internal provider.
  2. Env overrideDAGSTACK_ACTIVE_PAYMENT_PROVIDER=internal.
  3. Priority — the plugin with priority=50 (stripe) wins over priority=40 (internal).
  4. Ambiguity — if both had priority=50, the core raises AmbiguousPlugin at startup.

Step 4. The call

payment = registry.get_plugin("payment_provider")
result = await payment.charge(ChargeInput(amount_cents=1999, currency="USD", source="tok_visa"))
print(result.transaction_id)

Picking a specific plugin explicitly (when needed):

payment = registry.get_plugin("payment_provider", name="internal")

Switching the active plugin

Via an env var — no restart required:

export DAGSTACK_ACTIVE_PAYMENT_PROVIDER=internal
python main.py

Via a routing policy — at runtime, per-tenant:

:::info Phase 2 scope Per-call routing policies (set_routing_policy(kind, policy=...)) are part of the Phase 2 roadmap. In 0.1.0-rc.2 selection follows priority + env override + explicit name= only. For tenant-driven routing today, resolve the active provider in application code with registry.get_plugin("payment_provider", name=...). :::

Common errors

SymptomCause
AmbiguousPlugin: equal priority for kind=payment_provider at startupTwo plugins of the kind have equal priority and there is no env override. Differentiate the priorities or set DAGSTACK_ACTIVE_PAYMENT_PROVIDER=....
KindUnknown: payment_providerThe kind's hookspec is not registered; call registry.add_hookspecs(...) for the kind module before registry.discover("plugins/").
RuntimeNotSupportedThe plugin only declared runtime = "mcp_http", and the host adapter is not configured.

Singleton for other domains — the same pattern

The same walkthrough applies to any kind that needs "one active":

  • llm — one LLM backend per application (OpenAI / Anthropic / local).
  • vector_store — one vector store (Qdrant / pgvector).
  • orchestrator — one UoW orchestrator per runtime.
  • cache — one cache backend (Redis / memcached).
  • auth_provider — one primary auth (OAuth / SAML).

The mechanics are the same: declare the hookspec, two-plus implementations, pick by priority / env override / routing policy.

See also