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
- Python
- TypeScript
- Go
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
kind: payment_provider
kind_api_version: 1.0.0
description: Payment provider.
hooks:
- name: charge
dispatch: singleton
input_schema: schemas/charge.input.json
output_schema: schemas/charge.output.json
mcp_exposed: true
The hookspec is the same across all implementations; different types are emitted.
kind: payment_provider
kind_api_version: 1.0.0
description: Payment provider.
hooks:
- name: charge
dispatch: singleton
input_schema: schemas/charge.input.json
output_schema: schemas/charge.output.json
mcp_exposed: true
Step 2. Write two implementations
- Python
- TypeScript
- Go
[plugin]
name = "stripe"
kind = "payment_provider"
runtime = "in_process"
core_version = ">=0.1.0,<1.0.0"
priority = 50
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"}
[plugin]
name = "internal"
kind = "payment_provider"
runtime = "in_process"
core_version = ">=0.1.0,<1.0.0"
priority = 40
{
"plugin": {
"name": "stripe",
"kind": "payment_provider",
"runtime": "in_process",
"core_version": "^0.2",
"priority": 50
}
}
export class StripeProvider {
async charge(input: ChargeInput): Promise<ChargeOutput> {
// ...
}
}
[plugin]
name = "stripe"
kind = "payment_provider"
runtime = "in_process"
core_version = ">=0.1.0,<1.0.0"
priority = 50
package stripe
import (
"context"
pluginsystem "go.dagstack.dev/plugin-system"
)
type StripeProvider struct{}
func (p *StripeProvider) Setup(ctx context.Context, pluginCtx *pluginsystem.PluginContext) error {
return nil
}
func (p *StripeProvider) Charge(ctx context.Context, input ChargeInput) (ChargeOutput, error) {
// ...
return ChargeOutput{}, nil
}
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:
- Application routing policy (when supplied) — for example, per-tenant selection: premium tenants use Stripe, internal tenants use the internal provider.
- Env override —
DAGSTACK_ACTIVE_PAYMENT_PROVIDER=internal. - Priority — the plugin with
priority=50(stripe) wins overpriority=40(internal). - Ambiguity — if both had
priority=50, the core raisesAmbiguousPluginat startup.
Step 4. The call
- Python
- TypeScript
- Go
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")
:::warning TypeScript runtime ships in Phase 1
@dagstack/plugin-system@0.1.0-rc.2 exports only the spec-emitted types — VERSION, ToolV1, OrchestratorV1. The runtime (PluginRegistry, discover, dispatchers, contract suite) lands in Phase 1. Today: implement the kind contract against the published types, then host plugins through Python over mcp_stdio or wait for the Phase 1 release. See the TypeScript API reference for the planned shape.
:::
import (
"context"
pluginsystem "go.dagstack.dev/plugin-system"
)
disp := pluginsystem.NewDispatchSingleton[PaymentProvider](reg, "payment_provider")
payment, err := disp.Resolve()
if err != nil {
return err
}
resp, err := payment.Charge(context.Background(), ChargeInput{
AmountCents: 1999,
Currency: "USD",
Source: "tok_visa",
})
NewDispatchSingleton[T] is generic — T is the kind contract you cast plugin instances to. The dispatcher applies the same selection algorithm as the spec (priority + env override + ambiguity check).
Picking a specific plugin explicitly (when needed):
plugin, err := reg.ResolveSingleton("payment_provider")
// plugin.Unwrap() returns the bound instance; cast to the kind contract.
payment := plugin.Unwrap().(PaymentProvider)
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
| Symptom | Cause |
|---|---|
AmbiguousPlugin: equal priority for kind=payment_provider at startup | Two plugins of the kind have equal priority and there is no env override. Differentiate the priorities or set DAGSTACK_ACTIVE_PAYMENT_PROVIDER=.... |
KindUnknown: payment_provider | The kind's hookspec is not registered; call registry.add_hookspecs(...) for the kind module before registry.discover("plugins/"). |
RuntimeNotSupported | The 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
- Dispatch — overview of all five classes.
- ADR-0002 §1 singleton — the normative selection algorithm.
- Broadcast-collect — when you need every implementation at once.