Extend

Create a Plugin

Write a plugin once and it runs on every adapter, because it never touches your framework.

Beta

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:

halfmoduleimported bymay import
the declarationyour package's main entrythe contract, so everybodywhatever a browser bundles
the serveryour-package/serverthe server app onlyanything

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

@ts-kizuna/audit/src/index.ts
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,
    });
keywhat it is
namethe plugin's own name, whatever key an app installs it under
serverModulewhere the other half lives, named in the error if an app forgets it
routesthe routes it serves, in the contract, so pure data
propshow 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

@ts-kizuna/audit/src/server.ts
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),
        },
    }));
keywhat it is
routerhandlers for the plugin's own routes, typed against them
exportswhat 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

src/contract/k.ts
plugins: {
    audit: auditPlugin({ path: '/internal/audit' }),
}
src/server/api.ts
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:

package.json
{
    "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

On this page