Create a Plugin
Write a plugin once and it runs on every adapter, because it never touches your framework.
The plugin API is new and still settling. createPlugin, implementPlugin, and the shape of what each half returns may change before v2, so pin your version if you depend on them.
A plugin declares routes and hands back handlers. The adapter mounts them exactly as it mounts yours, so a plugin needs no express, hono, fastify or next import anywhere in it.
A plugin ships in two halves, in two modules:
| half | module | imported by | may import |
|---|---|---|---|
| the declaration | your package's main entry | the contract, so everybody | whatever a browser bundles |
| the server | your-package/server | the server app only | anything |
A plugin is declared on new Kizuna(), so whatever its declaration module imports lands in your contract's import graph, browser bundles included. Keep node:fs, a database driver or a heavy SDK in the server half.
The declaration
import { createPlugin } from '@ts-kizuna/core/plugin';
import { z } from 'zod';
import type { AuditExports } from './server.js';
export interface AuditPluginProps {
path?: `/${string}`;
}
export const auditPlugin = (props: AuditPluginProps = {}) =>
createPlugin<AuditExports>()({
name: 'audit',
serverModule: '@ts-kizuna/audit/server',
routes: {
recent: {
method: 'GET',
path: props.path ?? '/audit/recent',
responses: {
200: z.array(EntrySchema),
},
},
},
props,
});| key | what it is |
|---|---|
name | the plugin's own name, whatever key an app installs it under |
serverModule | where the other half lives, named in the error if an app forgets it |
routes | the routes it serves, in the contract, so pure data |
props | how the caller configured it, in the contract, so pure data |
import type is erased, so the declaration can name types the server half owns without importing its code. createPlugin<AuditExports>() types what handlers get, which is what the extra () is for. A plugin that exports nothing to handlers writes createPlugin({ ... }).
The server
import { implementPlugin } from '@ts-kizuna/core/adapter';
import { auditPlugin } from './index.js';
export interface AuditExports {
record: (routeKey: string) => void;
}
export const auditPluginServer = (config: { store: AuditStore }) =>
implementPlugin(auditPlugin, ({ props }) => ({
router: {
recent: async () => ({
status: 200 as const,
body: await config.store.recent(props.path),
}),
},
exports: {
record: (routeKey: string) => config.store.write(routeKey),
},
}));| key | what it is |
|---|---|
router | handlers for the plugin's own routes, typed against them |
exports | what every handler reaches at plugins.<name> |
implementPlugin reads its first argument for the type only, never calling it: that types router against the declared routes and exports against what the declaration promised.
props arrives from the contract. Anything live is your factory's own argument, which is why auditPluginServer takes store and the declaration does not.
The callback also receives api, for plugins that read the contract's routes. MCP uses it to build its tool list; most plugins ignore it.
What the app writes
plugins: {
audit: auditPlugin({ path: '/internal/audit' }),
}export const api = server.api({
router,
plugins: {
audit: auditPluginServer({ store }),
},
});Same key both times. Leave the second one out and server.api throws, naming your serverModule. See Plugins for the caller's side.
Answering with something that is not JSON
A plugin route can return rawResponse when its wire format is not a JSON body, which is how MCP serves JSON-RPC over server-sent events:
import { rawResponse } from '@ts-kizuna/core/adapter';
endpoint: async ({ body, headers }) => rawResponse(await transport.handleRequest(request)),Kizuna skips validation and rendering, and each adapter writes the response out in its own terms. Ordinary route handlers cannot reach rawResponse.
Staying adapter-agnostic
A plugin's handlers receive the same arguments yours do: params, query, body, headers, plus the adapter's own context. Build from the parsed inputs and the plugin runs everywhere.
If a plugin genuinely needs framework specifics, narrow its HandlerContext and it will compile only on the adapters that match.
Packaging
Two export subpaths, one per half:
{
"exports": {
".": "./dist/index.mjs",
"./server": "./dist/server.mjs"
}
}Bundle the main entry for a browser target in CI and the boundary holds itself. Kizuna does that for core and every first-party plugin.
Reference
- Plugins for installing and using one
mcpPluginfor a worked example- Create an Adapter if the runtime itself is missing