# Dynamic Plugin Loading — v1 Date: 2026-05-02 (revised) > **Status: shipped.** The implementation lives on `main`. This doc was > revised after the fact to match what actually shipped — three things > changed from the original design: > > 1. `handlers(self) => Layer` became late-binding `handlers() => Layer` > plus a `extensionService` Service tag on the spec. Cloud's > per-request executor pattern needed the Tag to be satisfied at > request time, not bake-in time. Local satisfies it eagerly via > `composePluginHandlers`, cloud satisfies it per request via > `providePluginExtensions`. > 2. `` was added in `@executor-js/sdk/client` > so frontend pages read `useSourcePlugins()` / `useSecretProviderPlugins()` > / `useClientPlugins()` from React context instead of taking > plugin lists as props. Routes/shells stop importing per-app > aggregator files. > 3. `SourcePlugin` and `SecretProviderPlugin` types moved to > `@executor-js/sdk/client` and are populated as fields on > `defineClientPlugin({ sourcePlugin, secretProviderPlugin })` so > everything frontend-facing flows through `virtual:executor/plugins-client`. ## Context Before this work, plugins were workspace deps statically imported in `apps/local/src/server/executor.ts` inside a `createLocalPlugins()` factory; the cloud app had its own equivalent in `apps/cloud/src/services/executor.ts`. They register secret providers, connection providers, and dynamic tools through `definePlugin`. The DX of authoring one was good — the problem was that adding a plugin meant editing host source. The goal: install plugins from npm and pick them up by editing config alone. The existing in-process plugin model already gets a lot right and we want to keep it: Effect-native everything, end-to-end type inference from schema → storage → extension → consumer (`executor[pluginId]`), scope-aware ctx, closure methods, zero-allocation static tools delegating via `staticSources(self) => [...]`. Whatever changes here must preserve those properties. What's missing is registration of three new surfaces from a plugin: - **API routes** — HTTP endpoints owned by the plugin, contributed as an `HttpApiGroup` so they compose into the host's existing typed `HttpApi` - **Frontend** — pages/widgets/components contributed to the host UI, with reactive `AtomHttpApi`-backed clients matching the existing `toolsAtom` / `sourcesAtom` pattern in `packages/react/src/api/atoms.tsx` - **SDK** — already present via `extension`; the new HTTP routes give the frontend a typed reactive client without hand-written fetch glue ## Goal and non-goals **v1 goals:** - A plugin is a single npm package. `bun add @executor-js/plugin-foo`, add it to `executor.config.ts`, restart, and it works. - Plugins can register: extension methods (today), API routes (new), frontend pages/widgets (new). - Frontend half gets a typed reactive client from the plugin's `HttpApiGroup` via the same `AtomHttpApi` pattern the core uses today. - A plugin author can write the whole thing importing only from `@executor-js/sdk` (and `@executor-js/sdk/client` on the frontend). Effect imports are optional — for authors who want them, not required for those who don't. - `executor.config.ts` is the single source of truth — same file consumed by the schema-gen CLI and the runtime. - Type inference end-to-end stays intact (`executor.foo.method()` autocompletes). **Non-goals for v1:** - Cloud / multi-tenant deployment. - Electron desktop dynamism. Desktop builds bake plugins at build time. - Sandboxing, capability enforcement, marketplace, signing. - Hot reload of plugin code (restart is fine). - Loading plugins by string spec at runtime (we use import-and-call instead; see decision #1). ## Reference research summary Surveyed five plugin systems in `.reference/`: - **pi-mono** — filesystem scanning + jiti, factory function + ExtensionAPI. Two-phase load (registration vs. action) is a clean idea. - **opencode** — string specs in JSONC, dynamic `import()`, hook trigger pattern with `(input, output)` mutation. - **openclaw** — manifest-first control plane (`openclaw.plugin.json` declares capabilities statically so the host can plan activation without loading code). - **emdash** — **closest match to our setup.** Astro/Vite + React, plugins are npm packages with separate `./` and `./admin` exports, registered via import-and-call in `astro.config.mjs`. `definePlugin({ id, hooks, routes, admin })` declarative shape. - **dynamic-software** — most ambitious; Cloudflare Worker Loader for cloud isolation, iframe + postMessage RPC for UI, Proxy-based typed API client. The pattern that fits us cleanest is emdash's: import-and-call in a config file, single npm package with separate server/client exports, host integrates via Vite. We don't need dynamic-software's Proxy RPC client because `@effect/platform` already gives us `HttpApiClient` plus `AtomHttpApi` for the reactive React side — both already used heavily in `packages/core/api/src/api.ts` and `packages/react/src/api/`. ## Decisions ### 1. Config is import-and-call, not string specs `executor.config.ts` holds real imports of plugin factory functions. Type inference flows naturally; no codegen step. ```ts // apps/local/executor.config.ts (after) import { defineExecutorConfig } from "@executor-js/sdk"; import { openApiPlugin } from "@executor-js/plugin-openapi"; import { mcpPlugin } from "@executor-js/plugin-mcp"; import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; export default defineExecutorConfig({ dialect: "sqlite", plugins: [ openApiPlugin(), mcpPlugin({ dangerouslyAllowStdioMCP: true }), fileSecretsPlugin(), ] as const, }); ``` "Dynamic" means npm-installable, not load-by-string-name. Same model as emdash. Codegen-based string specs (TanStack Router style) deferred to later if remote config becomes a need. ### 2. Plugin = single npm package, two exports ```jsonc // @executor-js/plugin-foo/package.json { "name": "@executor-js/plugin-foo", "type": "module", "exports": { "./server": "./dist/server.js", "./client": "./dist/client.js", }, "executor": { "id": "foo", "version": "0.1.0", }, } ``` ``` @executor-js/plugin-foo/ ├── package.json ├── src/ │ ├── server.ts # Effect, Node deps, definePlugin │ ├── client.tsx # React, defineClientPlugin │ └── shared.ts # Schemas, types shared across the boundary └── dist/ ``` Strict separation: server bundle never imports React, client bundle never imports Effect/Node modules. Shared types live in `src/shared.ts` and are imported by both halves. ### 3. Extend `PluginSpec` with optional `routes`; add parallel `defineClientPlugin` Server side: keep `definePlugin`. Add optional `routes` field (the `HttpApiGroup`) and `handlers` field (the typed `Layer`). No frontend concepts on the server side. Client side: separate primitive `defineClientPlugin` lives in `@executor-js/sdk/client`. Can only be imported in the `./client` entry, so React types never leak into server bundles. **Layering — extension is the canonical SDK; routes/handlers is optional HTTP transport.** The HTTP layer is _not a peer_ of the SDK; it's a transport over it. Plugin authors should treat extension as the implementation and write handlers as thin wrappers that delegate via the `self` parameter (same pattern as `staticSources(self) => [...]` already in the codebase). This keeps: - a single source of truth for the plugin's behavior - in-process callers paying zero serialization cost - HTTP callers getting auth/scope/observability middleware - error contracts identical across the two surfaces Three plugin shapes fall out of this layering: - **SDK-only.** Pure programmatic. No `routes`, no `handlers`, no `./client` export. Examples: file-secrets, keychain, onepassword, anything that's a utility for other plugins or scripts. CLI/embedded consumers use `executor..method()`. Vite plugin notices no `./client` and skips the plugin in the frontend bundle entirely. - **Both.** Extension _and_ routes/handlers _and_ a `./client`. Examples: openapi, mcp, anything that needs a frontend. Routes are thin wrappers over extension methods. - **HTTP-only.** Rare — webhook receivers, OAuth callback URLs. Routes without a meaningful in-process equivalent. May or may not have an extension. ### 4. Routes are an `HttpApiGroup`; client uses `AtomHttpApi` Plugins ship the same primitive the core uses (`HttpApiGroup` from `effect/unstable/httpapi`). The host composes via the existing `addGroup` helper at `packages/core/api/src/api.ts:21`. OpenAPI annotations and docs flow automatically. For the frontend, plugins build a per-plugin `AtomHttpApi.Service` against their own group, wrapped behind a `createPluginAtomClient(group, opts)` helper. The resulting atoms are consumed via the existing `useAtomValue` / `useAtomSet` + `AsyncResult.match` idiom — same pattern as `toolsAtom`, `sourcesAtom`, etc. ### 5. SDK re-exports the Effect HttpApi/Schema primitives Plugin authors can write a complete plugin importing only from `@executor-js/sdk` (server) and `@executor-js/sdk/client` (frontend). The SDK re-exports `Schema`, `HttpApi`, `HttpApiGroup`, `HttpApiEndpoint`, `HttpApiBuilder`, `Effect` so authors who don't want to dig into Effect don't have to. Authors who _do_ want Effect-native code keep importing from `effect` directly. This keeps the door open without forcing the dependency. ### 6. Skip `capabilities` declaration for v1 `["read:secrets", "network:fetch"]` style declarations are useful for sandboxing but unenforced metadata is just noise. Add when there's a real isolation story. ### 7. `executor.config.ts` is the single source of truth Today the schema-gen CLI reads `executor.config.ts` but the runtime hardcodes `createLocalPlugins()` in `apps/local/src/server/executor.ts:96`. Consolidate: the runtime imports from `executor.config.ts` too. One list, two consumers. ### 8. Canary plugin: build new tiny one first, then migrate openapi Validate the shape with a minimal `@executor-js/plugin-example` (one extension method, one route, one widget) before changing real plugins. ### 9. Cross-plugin pluggable capabilities: per-capability typed fields Some capabilities (secrets, eventually artifacts, maybe more) are "pluggable" — many plugins can implement them, the host swaps between providers via config, consumer code stays agnostic. Don't generalize this. The existing `secretProviders` field on the spec already handles this exact pattern for secrets and works fine: ```ts // what plugins do today secretProviders: (ctx) => [makeScopedProvider(...)] ``` Each new pluggable capability gets its _own_ typed field on `PluginSpec`, same shape. When artifacts lands, that's `artifactStore: () => ArtifactStore`. If connection providers want to be modeled this way, the existing `connectionProviders` field already is. No `provides` / `requires` / `service` machinery, no Effect-Tag-as-generic- primitive abstraction, no "protocols" concept, no naming debate. The artifacts note's "protocol" framing translates directly to "the v2 `artifactStore` field on `PluginSpec`." If we eventually have 5–6 of these and the boilerplate genuinely screams for generalization, we generalize then. Likely won't. For v1 nothing changes here — `secretProviders` is what it is, and artifacts aren't shipping yet. ## New type sketches ### `PluginSpec` extension Existing fields unchanged. Three new fields: ```ts // packages/core/sdk/src/plugin.ts (extension) import type { Context, Layer } from "effect" import type { HttpApiGroup } from "effect/unstable/httpapi" export interface PluginSpec< TId, TExtension, TStore, TSchema, TExtensionService extends Context.Service | undefined = undefined, THandlersLayer extends Layer.Layer = Layer.Layer, > { // ... existing: id, schema, storage, extension, staticSources, // invokeTool, secretProviders, etc. /** npm package name. The Vite plugin emits * `import "${packageName}/client"` into the frontend bundle, so the * same `executor.config.ts` drives both server and client. Author * writes the same string they publish to npm — no transforms, no * scope conventions, no fallbacks. Required for plugins that ship * a `./client` entry; omit for SDK-only plugins. */ readonly packageName?: string /** HttpApiGroup contributed by this plugin. Composed into the host's * HttpApi via the `composePluginApi` helper at runtime. The host * serves it under whatever base URL the host wires (`/api` for the * local app's vite middleware) and supplies auth + scope * middleware. Endpoints automatically appear in the executor OpenAPI * doc and the typed client. */ readonly routes?: () => HttpApiGroup.Any /** Late-binding handlers Layer for this plugin's group. The layer * leaves the plugin's extension as a Service Tag requirement (see * `extensionService` below). The host satisfies it however its * runtime wants: * - local: at boot via `Layer.succeed(extensionService)(executor[id])` * inside `composePluginHandlers(plugins, executor)` * - cloud: per request via `Effect.provideService(extensionService, * requestExecutor[id])` inside the auth middleware via * `providePluginExtensions(plugins)(executor)` */ readonly handlers?: () => THandlersLayer /** Service tag the plugin's `handlers` layer requires. The established * pattern in each plugin's `*/api/handlers.ts`: `*Handlers` reads * `*ExtensionService`. The host binds the tag to the live extension — * at boot for local, per request for cloud. Pairs with `handlers`; * either both fields are set or neither. */ readonly extensionService?: TExtensionService } ``` **Why late-binding (`handlers()` not `handlers(self)`).** The original design had `handlers(self) => Layer` so the host called the factory with the boot-time extension and got a fully-resolved Layer. That fits local fine — one executor at boot, one Layer. Cloud builds a fresh executor per HTTP request (Cloudflare Workers + Hyperdrive needs fresh DB connections per request), so the executor that satisfies the extension Service is per-request, not per-boot. The late-binding shape lets the same plugin spec satisfy both: the `*Handlers` Layer is built once and consumes `*ExtensionService` from context; local provides it at boot via `Layer.succeed`, cloud provides it per request via `Effect.provideService`. Example plugin shape — group definition in `shared.ts` so client and server both import it: ```ts // @executor-js/plugin-foo/src/shared.ts import { HttpApiEndpoint, HttpApiGroup, Schema } from "@executor-js/sdk"; export const Thing = Schema.Struct({ id: Schema.String, name: Schema.String, }); export const FooApi = HttpApiGroup.make("foo") .add(HttpApiEndpoint.get("listThings")`/things`.addSuccess(Schema.Array(Thing))) .add( HttpApiEndpoint.post("syncThing")`/sync/:id` .setPath(Schema.Struct({ id: Schema.String })) .addSuccess(Thing) .addError(SyncError), ); ``` ```ts // @executor-js/plugin-foo/src/server.ts import { Context, definePlugin, Effect, HttpApi, HttpApiBuilder, } from "@executor-js/sdk" import { FooApi } from "./shared" // Bundle the group into a small HttpApi *for typing purposes only*. The // handlers Layer is keyed by the FooApi group's identity, so it composes // cleanly into the host's FullApi at runtime regardless of what other // groups are around it. const FooApiBundle = HttpApi.make("foo").add(FooApi) interface FooExtension { readonly listThings: () => Effect.Effect readonly syncThing: (id: string) => Effect.Effect } // Service tag for late-binding — the host satisfies this at boot // (local) or per request (cloud). Same pattern as the established // `*ExtensionService` in each plugin's `*/api/handlers.ts`. export class FooExtensionService extends Context.Service< FooExtensionService, FooExtension, >()("FooExtensionService") {} const FooHandlers = HttpApiBuilder.group(FooApiBundle, "foo", (h) => h .handle("listThings", () => Effect.gen(function* () { const ext = yield* FooExtensionService return yield* ext.listThings() }), ) .handle("syncThing", ({ path }) => Effect.gen(function* () { const ext = yield* FooExtensionService return yield* ext.syncThing(path.id) }), ), ) export const fooPlugin = definePlugin((opts?: FooConfig) => ({ id: "foo" as const, packageName: "@executor-js/plugin-foo", storage: () => ({ /* ... */ }), // Canonical implementation. CLI/tests/embedded callers and the HTTP // handlers all hit this same code path. extension: (ctx): FooExtension => ({ listThings: () => /* Effect — canonical impl */, syncThing: (id: string) => /* Effect — canonical impl */, }), routes: () => FooApi, handlers: () => FooHandlers, extensionService: FooExtensionService, })) ``` Why `routes` and `handlers` are split: `routes` is the API description (a group), `handlers` is the implementation Layer keyed by group identity. The host needs the group at composition time (to build `FullApi`) and the Layer at serve time (to provide handler implementations). `extensionService` is the third leg: the bridge that lets the host bind the live extension (at boot or per request) without the plugin author having to write two handler shapes. ### `defineClientPlugin` Lives in `@executor-js/sdk/client`. Server bundles cannot import this module. ```ts // packages/core/sdk/src/client.ts export interface ClientPluginSpec { readonly id: TId; /** Pages contributed to the host's TanStack router. Mounted under * /plugins/{id}/{path}. Sidebar nav metadata declared on the route. */ readonly pages?: readonly PageDecl[]; /** Dashboard / overview widgets the host can render in known slots. */ readonly widgets?: readonly WidgetDecl[]; /** Components the host can render in named slots (e.g., source-detail * panels, secret-picker variants). Slot names are part of the host * contract — plugin opts in by registering. */ readonly slots?: Record; /** Source-plugin contribution — populated by plugins that expose * `kind` rows in the core `source` table (openapi, mcp, graphql, * google-discovery). The host's sources page derives its provider * list from the union of every loaded plugin's `sourcePlugin` * via `useSourcePlugins()`. */ readonly sourcePlugin?: SourcePlugin; /** Secret-provider-plugin contribution — populated by plugins that * also ship a `secretProviders` server-side capability AND want to * expose a settings card on the host's secrets page. Surfaced via * `useSecretProviderPlugins()`. */ readonly secretProviderPlugin?: SecretProviderPlugin; } type PageDecl = { path: string; // "/", "/edit/$id" component: ComponentType; nav?: { label: string; section?: string }; }; type WidgetDecl = { id: string; component: ComponentType; size?: "half" | "full"; }; export const defineClientPlugin = (spec: ClientPluginSpec) => spec; ``` ### `createPluginAtomClient` — typed reactive client per plugin Plugins build their own `AtomHttpApi.Service` against their own group bundled into a small `HttpApi`. A helper hides the boilerplate so plugin authors write one line per atom. Same shape as the existing `ExecutorApiClient` in `packages/react/src/api/client.tsx:11`. ```ts // packages/core/sdk/src/client.ts (helper) import { HttpApi } from "effect/unstable/httpapi"; import { FetchHttpClient } from "effect/unstable/http"; import * as AtomHttpApi from "effect/unstable/reactivity/AtomHttpApi"; export const createPluginAtomClient = ( group: G, opts: { pluginId: string }, ) => { const bundle = HttpApi.make(`plugin-${opts.pluginId}`).add(group); return AtomHttpApi.Service<`Plugin_${string}Client`>()(`Plugin_${opts.pluginId}Client`, { api: bundle, httpClient: FetchHttpClient.layer, baseUrl: `/_executor/plugins/${opts.pluginId}`, }); }; ``` Plugin author writes: ```tsx // @executor-js/plugin-foo/src/client.tsx import { defineClientPlugin, createPluginAtomClient, useAtomValue, useAtomSet, AsyncResult, } from "@executor-js/sdk/client"; import { FooApi } from "./shared"; const FooClient = createPluginAtomClient(FooApi, { pluginId: "foo" }); export const fooThingsAtom = FooClient.query("foo", "listThings", { timeToLive: "30 seconds", reactivityKeys: ["foo:things"], }); export const fooSync = FooClient.mutation("foo", "syncThing"); const FooPage = () => { const things = useAtomValue(fooThingsAtom); const doSync = useAtomSet(fooSync, { mode: "promise" }); return AsyncResult.match(things, { onInitial: () => , onFailure: () =>

Failed to load

, onSuccess: ({ value }) => doSync({ path: { id } })} />, }); }; export default defineClientPlugin({ id: "foo" as const, pages: [{ path: "/", component: FooPage, nav: { label: "Foo" } }], widgets: [{ id: "foo-status", component: FooStatus, size: "half" }], }); ``` Type inference: `FooClient.query("foo", "listThings", ...)` is fully typed against `FooApi` — same checks the existing `ExecutorApiClient.query("tools", "list", ...)` performs. No codegen, no host-wide composed-API typing required. Each plugin is self-contained in its client typing. Server-side composition: the host calls four helpers in `@executor-js/api` (also re-exported from `@executor-js/api/server`). Each plugin's handlers Layer is keyed by its group's identity, so it slots into the composed API without the host needing the typed structure. ```ts import { composePluginApi, // routes() → composed HttpApi composePluginHandlers, // handlers() + extensionService bound eagerly composePluginHandlerLayer, // handlers() merged, Tag still unbound providePluginExtensions, // per-request Tag binding } from "@executor-js/api/server" // Local — single boot-time executor; satisfy Tags eagerly. const FullApi = composePluginApi(plugins) const SpecHandlers = composePluginHandlers(plugins, executor) // self bound const ServerLive = HttpApiBuilder.layer(FullApi).pipe( Layer.provide(CoreHandlers), Layer.provide(SpecHandlers), // ... ) // Cloud — per-request executor; satisfy Tags inside an HttpRouter middleware. const FullApi = composePluginApi(plugins) const SpecHandlers = composePluginHandlerLayer(plugins) // Tag still unbound const provideExt = providePluginExtensions(plugins) const ExecutionStackMiddleware = HttpRouter.middleware<{ provides: AuthContext | ExecutorService | ExecutionEngineService | , }>()( Effect.gen(function* () { return (httpEffect) => Effect.gen(function* () { const executor = yield* makeExecutionStack(...) return yield* httpEffect.pipe( Effect.provideService(ExecutorService, executor), provideExt(executor), // binds each plugin's Tag per request ) }) }), ) ``` Effect errors flow through the existing typed-error machinery — same as core handlers in `packages/core/api/src/handlers/`. ### Loader: `executor.config.ts` → runtime + frontend bundle Single config, two consumers. **Backend** (replaces `createLocalPlugins`): ```ts // apps/local/src/server/executor.ts (after) import config from "../../executor.config.ts"; const executor = yield * createExecutor({ scopes: [scope], adapter, blobs, plugins: config.plugins, onElicitation: "accept-all", }); ``` The plugins are already configured (factory called) by the time `executor.config.ts` is evaluated, so no async loader needed. **Frontend** — Vite plugin reads the same config, resolves each plugin's `./client` export, exposes a virtual module: ```ts // packages/vite-plugin-executor/src/index.ts (pseudocode) export default function executorVite(): Plugin { return { name: "executor-plugins", resolveId(id) { if (id === "virtual:executor/plugins-client") return "\0" + id; }, async load(id) { if (id !== "\0virtual:executor/plugins-client") return; const config = await loadExecutorConfig(); const imports = config.plugins.map((p, i) => `import p${i} from "${p.id}/client"`).join("\n"); const list = config.plugins.map((_, i) => `p${i}`).join(", "); return `${imports}\nexport const plugins = [${list}]`; }, }; } ``` Host app consumes via `` at the root, then focused hooks (`useSourcePlugins` / `useSecretProviderPlugins` / `useClientPlugins`) inside pages and shared components. The host's routes don't import the virtual module or any per-app aggregator file — `__root.tsx` is the only place that touches `virtual:executor/plugins-client`. ```tsx // apps/local/src/routes/__root.tsx import { createRootRoute } from "@tanstack/react-router"; import { ExecutorProvider } from "@executor-js/react/api/provider"; import { ExecutorPluginsProvider } from "@executor-js/sdk/client"; import { plugins as clientPlugins } from "virtual:executor/plugins-client"; import { Shell } from "../web/shell"; export const Route = createRootRoute({ component: () => ( ), }); ``` ```tsx // e.g. inside packages/react/src/pages/sources.tsx import { useSourcePlugins } from "@executor-js/sdk/client"; export function SourcesPage() { const sourcePlugins = useSourcePlugins(); // ... } ``` The catch-all `/plugins/$pluginId/$` route uses `useClientPlugins()` to look up which plugin's pages to mount. HMR works because the virtual module is part of Vite's graph. Adding a plugin needs a Vite restart (not a hot update — config changed). ## End-to-end example plugin ``` @executor-js/plugin-example/ ├── package.json └── src/ ├── server.ts ├── client.tsx └── shared.ts ``` ```ts // src/shared.ts — only @executor-js/sdk imports, no raw effect imports import { HttpApiEndpoint, HttpApiGroup, Schema } from "@executor-js/sdk"; export const Greeting = Schema.Struct({ message: Schema.String, count: Schema.Number, }); export type Greeting = typeof Greeting.Type; export const ExampleApi = HttpApiGroup.make("example").add( HttpApiEndpoint.post("greet", "/greet", { payload: Schema.Struct({ name: Schema.String }), success: Greeting, }), ); ``` ```ts // src/server.ts import { Context, definePlugin, Effect, HttpApi, HttpApiBuilder } from "@executor-js/sdk"; import { ExampleApi } from "./shared"; const ExampleApiBundle = HttpApi.make("example").add(ExampleApi); interface ExampleExtension { readonly greet: ( name: string, ) => Effect.Effect<{ readonly message: string; readonly count: number }>; } export class ExampleExtensionService extends Context.Service< ExampleExtensionService, ExampleExtension >()("ExampleExtensionService") {} const ExampleHandlers = HttpApiBuilder.group(ExampleApiBundle, "example", (h) => h.handle("greet", ({ payload }) => Effect.gen(function* () { const ext = yield* ExampleExtensionService; return yield* ext.greet(payload.name); }), ), ); export const examplePlugin = definePlugin(() => ({ id: "example" as const, packageName: "@executor-js/plugin-example", storage: () => ({ count: 0 }), // Canonical implementation lives here. extension: (ctx): ExampleExtension => ({ greet: (name: string) => Effect.sync(() => { ctx.storage.count += 1; return { message: `hello ${name}`, count: ctx.storage.count, }; }), }), routes: () => ExampleApi, handlers: () => ExampleHandlers, extensionService: ExampleExtensionService, })); export default examplePlugin; ``` ```tsx // src/client.tsx import { defineClientPlugin, createPluginAtomClient, useAtomSet } from "@executor-js/sdk/client"; import { useState } from "react"; import { ExampleApi } from "./shared"; const ExampleClient = createPluginAtomClient(ExampleApi, { pluginId: "example" }); const greetAtom = ExampleClient.mutation("example", "greet"); const ExamplePage = () => { const [name, setName] = useState("world"); const [result, setResult] = useState(); const doGreet = useAtomSet(greetAtom, { mode: "promise" }); return (
setName(e.target.value)} /> {result &&
{result}
}
); }; export default defineClientPlugin({ id: "example" as const, pages: [{ path: "/", component: ExamplePage, nav: { label: "Example" } }], }); ``` ```jsonc // package.json { "name": "@executor-js/plugin-example", "type": "module", "exports": { "./server": "./dist/server.js", "./client": "./dist/client.js", }, "executor": { "id": "example", "version": "0.1.0" }, "peerDependencies": { "@executor-js/sdk": "workspace:*", "react": "catalog:", }, } ``` User adds it: ```ts // apps/local/executor.config.ts import { examplePlugin } from "@executor-js/plugin-example/server"; plugins: [, /* ... */ examplePlugin()]; ``` ## SDK-only plugin example Many plugins don't need HTTP at all — utilities, providers, anything used purely from other plugins or scripts. The plugin shape collapses to one file with no `./client` export, no `routes`, no `handlers`. ``` @executor-js/plugin-rate-limiter/ ├── package.json └── src/ └── server.ts ``` ```jsonc // package.json { "name": "@executor-js/plugin-rate-limiter", "type": "module", "exports": { "./server": "./dist/server.js" }, "executor": { "id": "rateLimiter", "version": "0.1.0" }, "peerDependencies": { "@executor-js/sdk": "workspace:*" }, } ``` ```ts // src/server.ts import { definePlugin, defineSchema, Effect, Schema } from "@executor-js/sdk"; interface RateLimiterConfig { defaultLimit?: number; } class RateLimitExceeded extends Schema.TaggedError()("RateLimitExceeded", { key: Schema.String, retryAfterMs: Schema.Number, }) {} export const rateLimiterPlugin = definePlugin((opts: RateLimiterConfig = {}) => ({ id: "rateLimiter" as const, schema: defineSchema({ rate_buckets: { fields: { key: { type: "string", primary: true }, tokens: { type: "number" }, updated_at: { type: "number" }, }, }, }), storage: ({ adapter }) => ({ adapter, defaultLimit: opts.defaultLimit ?? 60, }), extension: (ctx) => ({ check: (key: string, cost = 1) => Effect.gen(function* () { const bucket = yield* readOrCreateBucket(ctx.storage, key); const refilled = refill(bucket, ctx.storage.defaultLimit); if (refilled.tokens < cost) { return yield* new RateLimitExceeded({ key, retryAfterMs: estimateRetry(refilled), }); } yield* writeBucket(ctx.storage, key, refilled.tokens - cost); return { allowed: true, remaining: refilled.tokens - cost }; }), reset: (key: string) => Effect.tryPromise(() => ctx.storage.adapter.delete({ model: "rate_buckets", where: [["key", "=", key]], }), ), }), })); ``` Pure programmatic consumption — works identically from CLI, tests, embedded library use, or another plugin's `extension`: ```ts const result = yield * executor.rateLimiter.check("user-123", 5); // ^? { allowed: true; remaining: number } yield * executor.rateLimiter .check("user-123", 1000) .pipe( Effect.catchTag("RateLimitExceeded", (err) => Effect.log(`hit limit on ${err.key}, retry in ${err.retryAfterMs}ms`), ), ); ``` What the host does with this plugin: - **Backend** composes it into `createExecutor`, exposes `executor.rateLimiter.*`. ✅ - **HTTP server** sees no `routes`/`handlers` — mounts nothing. ✅ - **Vite plugin** sees no `./client` export in `package.json`'s exports map — adds nothing to the frontend bundle. ✅ - **CLI** uses `executor.rateLimiter.*` directly. ✅ The plugin is invisible to anything not calling it — no HTTP surface, no frontend bundle cost, no auth surface to review. ## Sequencing Build order — all shipped. 1. ✅ **Consolidate config.** `apps/local/src/server/executor.ts` reads from `executor.config.ts`. `createLocalPlugins` deleted. The cloud app's equivalent (`createOrgPlugins` in `apps/cloud/src/services/executor.ts`) also gone — driven from `apps/cloud/executor.config.ts` via a `(deps) => plugins` factory shape. 2. ✅ **SDK re-exports.** `Context`, `Effect`, `Layer`, `Schema`, `Data`, `Option` plus `HttpApi*` re-exported from `@executor-js/sdk`. `@executor-js/sdk/client` mirrors with `Schema`, `HttpApi*`, `AsyncResult`, `Atom`, `AtomHttpApi`, and React hooks. 3. ✅ **`routes` + `handlers` + `extensionService` on `PluginSpec`.** Host-side composition lives in `packages/core/api/src/plugin-routes.ts`: `composePluginApi`, `composePluginHandlers`, `composePluginHandlerLayer`, `providePluginExtensions`. The local app uses the eager pair (`composePluginHandlers`); cloud uses the late-binding pair (`composePluginHandlerLayer` + `providePluginExtensions` per request). 4. ✅ **`defineClientPlugin` + `createPluginAtomClient` + Vite plugin.** `@executor-js/sdk/client` exports the factory and helper. `@executor-js/vite-plugin` resolves each plugin's `./client` from `executor.config.ts` and exposes `virtual:executor/plugins-client`. 5. ✅ **`@executor-js/plugin-example`.** End-to-end proof: shared group, server with extension/routes/handlers/extensionService, client with `defineClientPlugin` + reactive page. 6. ✅ **Migrate every plugin.** `openapi`, `mcp`, `google-discovery`, `onepassword`, `graphql`, `workos-vault`, `keychain`, `file-secrets` all driven by spec. Each that ships UI also has a `./client` `defineClientPlugin` exposing its `sourcePlugin` / `secretProviderPlugin`. 7. ✅ **`` + hooks.** Host wraps once at `__root.tsx`; pages call `useSourcePlugins()` / `useSecretProviderPlugins()` / `useClientPlugins()`. Per-route imports of plugin lists are gone. ## Open questions / deferred - **Pluggable artifacts.** When workflows lands, add an `artifactStore` field on `PluginSpec` shaped like `secretProviders` — typed, no generic abstraction. The artifacts note's "protocol package + provider plugin + feature plugin" translates to "shared interface package + provider plugin's `artifactStore` field + consumer plugin reading from `ctx`." - **Codegen-based string specs.** If we ever want config from a database or remote URL, switch to strings + a TanStack-Router-style generated `.d.ts`. Not needed until multi-tenant. - **Electron dynamism.** Currently desktop bakes plugins at build time. To let desktop users install plugins post-ship, would need either a runtime ESM story (import maps) or a "plugin pack" prebuilt-bundle model. Out of scope for v1. - **Sandboxing.** Plugins run in-process with full host trust. Worth revisiting when we have untrusted plugins (marketplace) — likely via dynamic-software's Worker Loader pattern or similar. - **Per-version plugin isolation.** Cloud-only concern. - **Hot reload of plugin code.** Restart is fine for v1; would be nice but costs significant design effort. ## References **Reference research (pre-design):** - emdash: `astro.config.mjs` import-and-call pattern, `package.json` exports for separate admin entry — see `.reference/emdash/demos/plugins-demo/`. - dynamic-software: PluginManifest + capability declarations idea (deferred); Worker Loader for cloud isolation — see `.reference/dynamic-software/`. - openclaw: manifest-first control plane idea (deferred but worth revisiting when we add capability enforcement). **Shipped code:** - Plugin contract: `packages/core/sdk/src/plugin.ts` (`PluginSpec`, `definePlugin`). - Client contract: `packages/core/sdk/src/client.ts` (`defineClientPlugin`, `ExecutorPluginsProvider`, `useSourcePlugins`, `useSecretProviderPlugins`, `useClientPlugins`, `createPluginAtomClient`). - Host composition helpers: `packages/core/api/src/plugin-routes.ts` (`composePluginApi`, `composePluginHandlers`, `composePluginHandlerLayer`, `providePluginExtensions`). - Vite plugin: `packages/core/vite-plugin/src/index.ts`. - Canary plugin: `packages/plugins/example/`. - Local config + wiring: `apps/local/executor.config.ts`, `apps/local/src/server/main.ts`, `apps/local/src/routes/__root.tsx`. - Cloud config + wiring: `apps/cloud/executor.config.ts`, `apps/cloud/src/api/protected-layers.ts`, `apps/cloud/src/api/protected.ts`, `apps/cloud/src/routes/__root.tsx`. - Pluggable-capability precedent: existing `secretProviders` field on `PluginSpec`. When artifacts ship, the new `artifactStore` field follows the same per-capability-typed-field pattern. **Adjacent plans:** - `notes/artifacts-workflows-and-generated-ui.md` — the artifacts/workflows plan. Uses "protocol" terminology that we're not adopting; read its "ArtifactStoreProtocol" as "the typed interface that goes into the `artifactStore` field." See decision #9. - Earlier plugin first-principles thinking: `personal-notes/plugin-system-first-principles.md`, `personal-notes/plugin-system-primitive-and-use-cases.md`.