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 and the shape of what it returns may change while v2 is in beta, so pin your version if you depend on them.
A plugin declares routes and hands back the handlers that answer them. The adapter mounts them exactly as it mounts yours, so a plugin imports no framework and works on all four.
A plugin is one module. It is named on kizuna.config.ts, which stays on the server, so it may import anything your handlers may: Node built-ins, a database driver, a heavy SDK.
createPlugin
import { z } from 'zod';
import { createPlugin, type RoutePath, type WithSlug } from 'kizunajs/plugin';
export interface AuditPluginProps<Slug extends string = 'audit'> {
/**
* The key this plugin installs at, and what handlers reach it under.
*/
slug?: Slug;
path?: RoutePath;
store: AuditStore;
}
const declare = (slug: string, props: AuditPluginProps<string>) =>
createPlugin({
slug,
routes: {
recent: {
method: 'GET',
path: props.path ?? '/audit/recent',
summary: 'The most recent audit entries',
responses: {
200: z.array(EntrySchema),
},
},
},
props,
serve: ({ store }) => ({
router: {
recent: async () => ({
status: 200,
body: await store.recent(),
}),
},
exports: {
record: (routeKey: string) => store.write(routeKey),
},
}),
});
export function auditPlugin<const Slug extends string = 'audit'>(
props: AuditPluginProps<Slug>
): WithSlug<ReturnType<typeof declare>, Slug> {
return declare(props.slug ?? 'audit', props as AuditPluginProps<string>) as never;
}| key | what it is |
|---|---|
slug | the key it installs at, and what handlers reach it under |
routes | the routes it serves |
props | how the caller configured it, handed back to serve so the app never restates it |
serve | what answers those routes, and what it hands handlers |
serve returns two things:
| key | what it is |
|---|---|
router | one handler per declared route, typed against them |
exports | what every handler reaches at plugins.<slug> |
A plugin that adds routes and nothing else returns router alone.
The slug wrapper
createPlugin takes a concrete slug, so the factory around it is what lets an app rename the plugin or install two of them. WithSlug carries the caller's literal slug through to the declaration, which is what types plugins.internalAudit on the handler side.
Default it to your plugin's own name and most apps never pass one:
plugins: [
auditPlugin({
store,
}),
auditPlugin({
slug: 'securityAudit',
path: '/internal/security-audit',
store: securityStore,
}),
],Reading the api
serve receives the assembled api as its second argument, so a plugin can read every route, tag, identity and schema the app declared:
serve: (props, api) => ({
router: {
endpoint: async () => ({
status: 200,
body: summarize(api.routes),
}),
},
}),That is how mcpPlugin finds the routes declaring tool, and how openApiPlugin builds a document without being handed one.
Plugin routes
A plugin's routes run through the same pipeline as yours, with the same validation, guards, access control and problem details. They never join api.routes, so they stay out of everything generated from it.
| your routes | a plugin's routes | |
|---|---|---|
| declared with | k.routes | the plugin |
served by api.mount | yes | yes |
| validated, guarded | yes | yes |
| in your fetch client | yes | no |
| in OpenAPI, Swift, Kotlin | yes | no |
If a plugin claims a path your routes already use, defineConfig throws and names both.
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 'kizunajs/adapter';
endpoint: async ({ body, headers }) => rawResponse(await transport.handleRequest(body, headers)),Kizuna skips validation and rendering, and each adapter writes the response out in its own terms. Ordinary route handlers cannot reach rawResponse.
Packaging
One entry, and its own package.json says how far it reaches:
{
"exports": {
".": "./dist/index.mjs"
},
"kizuna": {
"entries": {
".": "server"
}
}
}A plugin is always server, since it runs where the config does. kizuna.entries is what this repo's boundary test reads, and it holds every package to the reach it declares.
Reference
- Config for installing one
mcpPluginfor a worked example- Create an Adapter if the runtime itself is missing