RFC: AI Plugins #954
Replies: 2 comments 2 replies
|
Broad questions:
Technical questions/requests:
|
TBH, I feel like this idea is going back to the original
"i've already done the demo app and shown it to you", so this RFC doesn't stand alone? on the technical points, I could continue on there but I have other things I need to do this morning. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
RFC: The Plugin API
Summary
Add a plugin API to TanStack AI: a way to expose several named AI capabilities (chat and one-shot generation) behind one server handler and one typed client hook.
definePluginon the server,usePlugin/createPluginon the client, and typed factories (chatPlugin,generationPlugin, plus media wrappers) for each capability. Every plugin is also runnable in-process viaplugin.run(). Ships in a new@tanstack/ai-plugin-toolkitpackage.Background & Motivation
TanStack AI today gives you the individual activities (
chat(),generateImage(),generateSpeech(), ...) and framework hooks for a single chat. An app that offers several capabilities, say a studio that drafts a post, illustrates it, and narrates it, has to hand-wire each one: a separate route per activity, a separate client fetch or hook per call, and its own glue to keep request and response types in sync across the server/client boundary.There is no first-class way to declare a set of named capabilities once and get, from that single declaration, one server endpoint and one fully typed client surface. As apps combine chat with media generation, that glue is repeated in every project and drifts out of type-sync easily.
Beyond a single app, this shape is a distribution primitive. An open-source project can ship a specialized agent as a plugin: a chat capability wired to its own tools and MCP servers, plus any one-shot generators, published so users install it and drop it into their app with
usePlugin, and any client that speaks the endpoint can consume it. Inside a company, a team can build a reusable agent once and ship it across the org like a microservice: the same definition value hands every consumer a typed client, a mountable serverhandler, and an in-process.run(), so the agent is consumed the same way whether it runs in-process, behind an internal endpoint, or as a package on the registry. Because the definition is inert and holds no secrets, sharing it is safe; the packaged unit is "a set of typed capabilities," not a bespoke SDK per agent.Because the plugin factories are ordinary functions, a platform can wrap them into its own branded factory that pre-wires its services while leaving the agent's behavior fully in the user's hands. A platform offering, say, self-improving agents publishes
acmePlugin(config); the user configures system prompts, tools, and MCP servers, and the platform's models, telemetry, and improvement loop run under the hood:acmePluginreturns a normalchatPlugin, so it drops intodefinePluginbeside any other plugin and reaches the client as a fully typed surface. The platform ships one function; the user owns the configuration.This proposes that missing layer. It is additive: it builds on the existing activities and the AG-UI wire protocol, and changes neither.
Goals
definePluginproduces a serverhandlerand the typed shape the client hook consumes.definePlugin(server) andusePlugin/createPlugin(client). No third declaration; no generics at the call site.chatPlugin,generationPlugin, and media wrappers (imagePlugin,videoPlugin,audioPlugin,speechPlugin,transcriptionPlugin,summarizePlugin).definePluginvalue directly, with no server code or credentials leaking into the browser bundle.plugin.run()), not only over HTTP, so they compose in app code and in future primitives.zod(or any concrete schema library) dependency.Non-Goals
workflowPlugin;plugin.run()is its building block. Multi-step flows today are orchestrated by the app (see Proposed Design).PluginClient) or the framework hooks into the toolkit package; they stay in@tanstack/ai-clientand the framework packages, importing plugin types from the toolkit.Proposed Design
Server: name capabilities with factories, expose one
handler.Client: import the value, flat per-plugin options, no generics.
Direct execution: every plugin has
.run(), a sibling to.handler, returning the typed result in-process (no HTTP). It accepts raw input, aRequest, or an already-parsed body.How it works:
definePlugin(config)returns{ plugins, pluginKinds, handler }. The definition is inert: no adapters or connections are constructed until a request reacheshandler, so it is safe to import into an isomorphic module.pluginKinds(a name →'chat' | 'one-shot'map carried in the value) lets the client build the correct sub-client per name with no second declaration.usePlugin(def, options)reads reserved keys (connection/id/threadId) off the flat options and treats every other key as per-plugin options, building one chat client per chat plugin and one generation client per one-shot plugin.generationPluginwrappers whose input schema is a hand-rolled Standard Schema, so no concrete schema library is pulled into the runtime bundle. Apps wanting richer validation pass their own schema togenerationPlugindirectly.handlerand.run()share one request-build and validation core:handleremits the AG-UI SSE stream,.run()collects the terminal result instead.flowchart LR subgraph toolkit["@tanstack/ai-plugin-toolkit"] cp["chatPlugin / generationPlugin / media factories"] --> dp["definePlugin({...})"] end dp -->|".handler(request)"| sse["AG-UI SSE"] sse --> pc["PluginClient (@tanstack/ai-client)"] pc --> uc["usePlugin / createPlugin (framework hooks)"] dp -.->|".run(input)"| res["typed result (in-process)"]Alternatives Considered
A. Fixed capability set instead of an arbitrary named registry. Restrict
definePluginto a closed enum of known keys.Rejected: loses custom, app-named generations and the ability to declare several chat surfaces (
primaryChat,summaryChat). The chosen design keeps the media set as convenience factories over a genericgenerationPlugin, covering both the common and the custom case.B. A separate type-only client declaration. Declare the plugin kinds a second time on the client instead of binding off the definition value.
Rejected: a redundant second source of truth that drifts. The
definePluginvalue already carries names and kinds at runtime and is import-safe (inert, no secrets), so the client binds off it directly. Cost: the value's referenced adapter code is bundled inert into the client (see Risks).C.
.run()returns aResponse. Make each plugin a one-line HTTP endpoint andhandlera router over.run().Rejected: collapses streaming for chat and conflates "serve over HTTP" with "execute and get a result." The chosen direct-result form is what app-side orchestration and a future
workflowPluginneed; serving one plugin is still one line:Response.json(await plugin.run(request)).D. Ship inside
@tanstack/ai(a/pluginsubpath) instead of a dedicated package.Rejected: the authoring API is a distinct concern with its own dependency surface. A dedicated
@tanstack/ai-plugin-toolkitkeeps@tanstack/aifocused on activities and gives the plugin API a single home. Cost: the toolkit imports a few internals from@tanstack/ai(schema validation, SSE response, request parsing), which must be part of that package's public surface.E. Build server-side composition into the registry now. Let a plugin receive a context and call sibling plugins server-side, streaming sub-results within one request.
Rejected for the first version: it is a large protocol (sub-run streaming, client demultiplexing, per-sub-run surfaces) that most apps do not need, and it belongs in a dedicated orchestration primitive. Client-side orchestration covers the common case now;
.run()is the building block for a laterworkflowPlugin.F. Distribute agents as a network service only (HTTP / MCP), no shared package. For the distribution use case, ship each agent as an endpoint plus a hand-written or generated client; consumers call it over the wire.
Rejected as the primary consumption path: it drops compile-time types across the boundary and the in-process
.run()composability, and forces every consumer to re-describe the contract. Because a plugin ships as a definition value (types plus an inerthandler), a consumer imports it for a fully typed client and a mountable server, and can still expose it over HTTP or MCP on top. Network-only consumption stays possible; it is not the default, and it is not either/or with shipping the typed definition.Risks & Tradeoffs
definePluginvalue pulls referenced adapters into the client graph. Mitigation: callbacks never run client-side and hold no secrets (keys come from server env at request time); the definition is inert. Accepted; first-class lazy loading is a possible later optimization, not required..run()structured collection. Collecting{ text, structured }from the stream must not diverge from how structured output is produced. Mitigation: it reads the terminal structured-output completion event's object directly (the same source the stream processor uses), not a separate parser.test:sherif/test:knipgate it..run()synthesizes a request envelope in direct mode (generated ids, a syntheticRequest). Accepted;PluginRunOptionslets callers overridethreadId/runId/signal/forwardedProps.Rollout Plan
main: the new@tanstack/ai-plugin-toolkitpackage, pluspluginentry points on@tanstack/ai-clientand the four framework packages.minorfor@tanstack/ai-plugin-toolkit(new),@tanstack/ai,@tanstack/ai-client, and the framework packages..run()across all three input forms including the abort signal); an E2E route exercising a chat plugin, a one-shot plugin, a media plugin, and a direct.run()call; documentation samples typechecked in CI.workflowPluginadds server-side composition on top ofplugin.run().Open Questions
workflowPluginshape. Whether composition is a new plugin kind that receives the other plugins and calls their.run(), or a separatedefineWorkflow, is out of scope here but should stay compatible with.run()'s options surface.Approvers
TanStack AI maintainers.
All reactions