Plugin lifecycle
A plugin moves through three phases over the lifetime of an application. Each phase has a clear contract and a set of guarantees that you can rely on when writing code.
Phase 1. Registration
During the call to discover(path) the registry:
- Walks the supplied directory.
- Locates folders that contain a
dagstack.toml. - Validates the manifest against the pydantic schema.
- Imports the implementation module (Python:
plugin.py; TypeScript:plugin.ts/plugin.js; Go: determined by the manifest). - Creates an instance of the plugin class via the no-argument constructor.
- Stores the
(manifest, instance)pair in the registry.
At this stage setup has not been called yet — the plugin exists as an object, but neither resources nor configuration have been delivered to it. The constructor body should be trivial: no file operations, no network connections, no heavy computation.
- Python
- TypeScript
- Go
class MyPlugin:
def __init__(self) -> None:
# Correct: only initialise the fields that setup will populate.
self._client = None
self._config = None
:::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.
:::
// Correct: zero-value struct, initialisation happens in Setup.
type MyPlugin struct {
client *Client
config *Config
}
Phase 2. Initialisation
The registry's setup_all() method initialises every registered plugin:
- The registry builds the dependency graph of the plugins (when declared in the manifest).
topo_sortdetermines the initialisation order — plugin A is initialised before B if B depends on A.- For every plugin in this order the registry calls
setup(context), wherecontext: PluginContextcontains:config— the validated section of the plugin's configuration;resources— the injected standard resources (Clock,Rng,BlobStore,HttpClientand the others fromSTANDARD_RESOURCES);tenant— aTenantContextor aNoOpTenantContext;event_bus— the publish/subscribe event bus;logger— the structured logger fromdagstack/logger-spec.
Inside setup a plugin should:
- Create clients (HTTP, database), open connections.
- Read the configuration, store the values it needs.
- Subscribe to configuration changes (when reactivity is required).
- Register additional resources for dependent plugins.
Forbidden: business-logic calls (handling input data, sending user requests). setup is preparation only.
- Python
- TypeScript
- Go
class MyPlugin:
async def setup(self, context: PluginContext) -> None:
self._config = context.config
# Resources are injected at setup time and exposed as attributes
# on the per-plugin `ResourceContainer` (`ctx.resources`); the
# async `await ctx.resources.get(name)` form is also available.
self._http = context.resources.http_client
self._clock = context.resources.clock
self._client = MyClient(
base_url=self._config["base_url"],
http=self._http,
)
:::info Phase 2 scope
Live config-section subscriptions (on_section_change) are part of the Phase 2 dagstack/config-spec integration. In 0.1.0-rc.2 config is delivered once during setup.
:::
:::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"
"encoding/json"
pluginsystem "go.dagstack.dev/plugin-system"
)
func (p *MyPlugin) Setup(ctx context.Context, pluginCtx *pluginsystem.PluginContext) error {
// Decode the validated config section.
raw, err := json.Marshal(pluginCtx.Config)
if err != nil {
return err
}
if err := json.Unmarshal(raw, &p.config); err != nil {
return err
}
// Resolve resources by name; the user-side type assertion is required.
rawHTTP, err := pluginCtx.Resources.Get("http_client")
if err != nil {
return err
}
p.http = rawHTTP.(HTTPClient)
rawClock, err := pluginCtx.Resources.Get("clock")
if err != nil {
return err
}
p.clock = rawClock.(Clock)
p.client = NewMyClient(p.config.BaseURL, p.http)
return nil
}
:::info Live config-section subscriptions are Phase 2
The Go binding receives Config once during Setup. Subscriptions to live updates land alongside the dagstack/config-spec integration in Phase 2.
:::
Phase 3. Teardown
The registry's teardown_all() method runs the closing cycle:
- It calls
teardown()on every plugin in the reverse of the initialisation order. - If a plugin's
teardownraises, the exception does not abort the loop — exceptions are collected intoTeardownErrors. - After every
teardownhas run, the aggregated error (if any) is propagated upwards.
teardown should:
- Close connections (HTTP clients, sockets, file descriptors).
- Cancel subscriptions to configuration and events.
- Release injected resources that the plugin created itself (when applicable).
- Python
- TypeScript
- Go
async def teardown(self) -> None:
if self._client:
self._client.close()
:::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.
:::
func (p *MyPlugin) Teardown(ctx context.Context) error {
if p.client != nil {
return p.client.Close()
}
return nil
}
Inter-plugin dependencies
A plugin may explicitly declare that it depends on another plugin. The registry takes that into account during topo_sort.
[plugin]
name = "cross-encoder"
kind = "reranker"
runtime = "in_process"
core_version = ">=0.1.0,<1.0.0"
[[plugin.depends_on]]
kind = "embedder"
name = "openai_compatible"
Once that is in place, embedder.openai_compatible is initialised before reranker.cross-encoder. The dependent plugin can be retrieved via context.registry:
- Python
- TypeScript
- Go
async def setup(self, context: PluginContext) -> None:
self._embedder = context.registry.get_plugin(
"embedder", name="openai_compatible",
)
:::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.
:::
func (p *Reranker) Setup(ctx context.Context, pluginCtx *pluginsystem.PluginContext) error {
disp := pluginsystem.NewDispatchSingleton[Embedder](pluginCtx.Registry, "embedder")
embedder, err := disp.Resolve()
if err != nil {
return err
}
p.embedder = embedder
return nil
}
A cycle in the dependency graph causes a DependencyCycle error during setup_all().
Partial failure
If setup of one plugin throws an exception:
- The registry catches the exception.
- It calls
teardownon every plugin already initialised (in reverse order). - It propagates the original exception upwards.
This guarantees that a partially initialised registry is never left in a dangling state. From the user's point of view: setup_all() either completes successfully as a whole or it raises an exception and leaves the registry in the pre-initialisation state.
Sequence diagram
See also
- Plugin registry — a deeper model of errors and invariants.
- Writing a plugin — a hands-on example with
setup/teardown.