0043 — Plugin consumption SDK + workflows as the first opt-in core extraction
- Status: Accepted (#791)
- Date: 2026-06-09
- Builds on: ADR 0001 (extensibility), 0002 (workflows), 0026/0027 (plugins), 0038 (two-mode plugin UI +
src/extseam), 0009 (control stack)
Context
Plugins so far only contribute to the host — PluginRegistry.register_* adds tools, routers, recipe dirs, goal verifiers, middleware. None had to consume deep core capability. Workflows (ADR 0002) is the first feature where that asymmetry bites: it's a real subsystem (engine + registry + tools + an operator API + the Studio surface) that we want out of the default build (lean core, like GitHub → plugin), but its engine runs each step as a subagent — so as a plugin it must call back into core's subagent executor. There was no stable way to do that without importing graph.agent internals, which would couple the plugin to core's refactors.
Two needs, then: (1) a stable consumption surface plugins call into core; (2) prove it by extracting workflows onto it and shipping workflows off-by-default.
Decision
1. A plugin SDK with two explicit halves.
- Contribution —
PluginRegistry.register_*(unchanged): what a plugin adds. - Consumption — new
graph/sdk.py: what a plugin calls back into core. Plugins importfrom graph.sdk import …, nevergraph.agent/runtime.stateinternals, so core can refactor underneath them.
v1 is deliberately tiny — only what workflows needs: run_subagent(subagent_type, prompt, …) (wraps the existing run_manual_subagent, pulls config/stores from runtime state), subagent_types(), and config(). Grow it deliberately as more plugins tap core; this is the seam we lean on going forward.
Grown since:
complete(prompt, *, system=None, model_name=None)— a bare LLM completion (no tools, no agent loop, no persona), distinct fromrun_subagent's full tool-using subagent. The clean primitive for a plugin that just needs the model to answer a prompt — first consumer: the artifact plugin's interactivewindow.protoArtifact.ask()bridge (artifacts call back to the agent, thewindow.claude.completeanalog).
spawn_background(prompt, *, subagent_type, origin_session, label=None)+background_status(task_id)(#1635) — the background channel: spawn a detached subagent job (ADR 0050) that rides the full ADR 0070 results pipeline (push-resume nudge, KB-indexed report, report card), and poll its jobs-store row in between. Closes the hole where a plugin with campaign-scale work had to reach intoSTATE.background_mgrdirectly. First consumer: spacetraders exploration campaigns.
react_on(topic, *, prompt, job_id, session=…, debounce_s=0)(#1633) — reactive-rule sugar overregistry.on+run_in_session(ADR 0039): prompt-from-payload (None/empty skips the event), idempotent replace viajob_id, thread-safe trailing-edge debounce (a burst coalesces into ONE turn; the last event's prompt wins). Returns an unsubscribe fn. First consumer: spacetraders engine-event → turn rules.
knowledge_search(query, *, k=5, domain=None, epoch=None)/knowledge_add(content, *, domain="general", heading=None, epoch=None)(#1089) +knowledge_purge(domain, *, before=None)(#1634) — the knowledge channel and its lifecycle half. Add-and-search alone let a long-running plugin's knowledge go actively wrong with no way to retire it (spacetraders: weekly universe wipes → recalled routes reference dead markets).knowledge_purgehard-deletes a domain (optionally only rows older than an ISO timestamp), consistently across every index (rows, FTS, vectors; layered = private tier only — the commons stays curated); the optionalepochtag scopes a chunk to the era it was learned in, and an epoch-filtered search excludes other eras and untagged chunks from both rankings — a wipe becomes a new tag, not a delete. First consumer: spacetraders route/lesson memory.
2. Workflows becomes an opt-in plugin (plugins/workflows, enabled: false).
- The engine + registry move into the plugin (standalone — the engine was already decoupled from the runner via an injected
run_stepcallback). It injectssdk.run_subagentas that callback — the first real SDK consumer. - Tools (
run_workflow,save_workflow) register viaregister_tools; the operator API is the plugin's own/api/plugins/workflowsrouter (validation errors → 400). - The plugin publishes
STATE.workflow_registry+STATE.workflow_run, so the core chat/<recipe>slash-command keeps working by inversion — it reads those (bothNonewhen the plugin is off) instead of importing workflows. - Core is fully stripped: no
run_manual_workflow, no_build_task_toolsworkflow branch, no_build_workflow_registry, no/api/workflows, noworkflows_enabledconfig;graph/workflows/and the bundled recipes are deleted (recipes now ship in the plugin).
3. The Studio surface stays native React, via the src/ext seam (NOT an iframe).
src/ext/workflows.tsx calls registerSurface(...); ExtSurface gains requiresPlugin, so the rail item is hidden and the surface unreachable unless the plugin is enabled. No iframe rewrite — src/ext (ADR 0038 D3) already hosts trusted in-process React. The surface JS is bundled but inert when off ("not active by default", not "zero JS").
Consequences
- Lean core: the workflow engine/tools/recipes/API don't load unless the plugin is enabled. Default build is smaller; workflows is opt-in.
- The SDK is the durable artifact. It establishes the contribution-vs-consumption contract; future "plugins tapping core" reuse
graph/sdk.pyrather than reaching in. - Studio = Workflows (ADR 0020) is now a plugin-contributed, gated surface — the "control stack" (ADR 0009) altitude for workflows lives in the plugin.
- Tradeoff: the chat slash-command + the bundled-but-gated surface keep a thin core touch-point (
STATE.workflow_run,requiresPlugin). Truly-zero-core would mean a plugin-contributed slash-command hook + an iframe surface — deferred; the inversion is clean enough and preserves the UX. - Cross-plugin recipes (ADR 0027) still work for plugins loaded before workflows; a recipe dir from a later-loading plugin is a known edge (rare) — revisit with lazy registry build if it bites.