Create a Plugin

Package the code you reuse across projects, like email or push notifications, so any Kizuna app can install it.

Once you build more than one project with Kizuna, a lot of the code repeats: sending email, push notifications, checking which tenant a request belongs to. A plugin packages that code once, so every project can install it.

A plugin hands your route and job handlers functions to call, and can bring routes of its own.

Your first plugin

Describe what it sends

Each plugin gets its own folder. Start with the shape of an email:

src/plugins/email/types.ts
import { z } from 'zod';

export const EmailSchema = z.object({
    to: z.email(),
    subject: z.string().min(1),
    html: z.string(),
});

export type Email = z.infer<typeof EmailSchema>;

Write it

src/plugins/email/index.ts
import { z } from 'zod';
import { Resend } from 'resend';
import { definePlugin } from 'kizunajs/plugin';
import type { Email } from './types';

export const emailPlugin = definePlugin({
    slug: 'email',
    options: z.object({
        apiKey: z.string(),
        from: z.string(),
    }),
    setup: ({ options }) => {
        const resend = new Resend(options.apiKey);

        return {
            exports: {
                send: (email: Email) =>
                    resend.emails.send({
                        from: options.from,
                        ...email,
                    }),
            },
        };
    },
});

Install it

kizuna.config.ts
import { emailPlugin } from './src/plugins/email';

export default defineConfig({
    adapter: expressAdapter(),
    routes,
    plugins: [
        emailPlugin({
            apiKey: process.env.RESEND_API_KEY,
            from: 'Acme <hello@example.com>',
        }),
    ],
});

Use it

Every route handler and every job handler receives it under plugins, typed:

src/routes/users.ts
createUser: k
    .route({
        method: 'POST',
        path: '/users',
        body: CreateUserSchema,
        responses: {
            201: UserSchema,
        },
    })
    .handler(async ({ body, plugins }) => {
        const user = await db.users.create(body);

        await plugins.email.send({
            to: user.email,
            subject: 'Welcome',
            html: `<p>Welcome, ${user.name}.</p>`,
        });

        return {
            status: 201,
            body: user,
        };
    }),

definePlugin

FieldWhat it gives the plugin
slugWhat handlers reach it under: plugins.email
optionsA Zod schema for what the app passes in
setupBuilds the plugin from its options and returns what it brings

setup returns any of these:

KeyWhat it is
exportsFunctions every route and job handler reaches at plugins.<name>
routesRoutes an outside system calls, like a webhook
generatorsFiles kizuna generate writes, built with defineGenerator

Everything setup returns shares what it creates, so the Resend client is made once and used by all of it.

setup runs when the config assembles, which includes kizuna generate. So it creates clients, and leaves connections and file reads to the handlers, the same as route modules.

Options

Every plugin's options are validated. options has to be a Zod schema, so what the plugin reads is always what it declared, and you never write checks of your own. The app's options are validated once, when the config assembles, and typed from the schema everywhere the plugin reads them.

src/plugins/email/index.ts
options: z.object({
    apiKey: z.string(),
    from: z.string(),
    replyTo: z.email().optional(),
}),

A misconfigured plugin stops the app from starting, and the error names the plugin and the field. Secrets come in through options, so a missing environment variable is caught the same way:

[kizuna] Plugin 'email' has invalid options: apiKey is required

A type from an SDK

An SDK's options usually come as a plain TypeScript type. Take it with z.custom, and the field is typed as the SDK declares it:

src/plugins/email/index.ts
import { Resend, type ResendOptions } from 'resend';

export const emailPlugin = definePlugin({
    slug: 'email',
    options: z.object({
        apiKey: z.string(),
        from: z.string(),
        resend: z.custom<ResendOptions>().optional(),
    }),
    setup: ({ options }) => {
        const resend = new Resend(options.apiKey, options.resend);

        // ...
    },
});

z.custom checks nothing unless you pass it a check, so the SDK validates that field when it reads it.

Exports

Every handler reaches a plugin's exports at plugins.<name>. Take one object with named fields, like every other kizuna call:

await plugins.email.send({
    to: user.email,
    subject: 'Welcome',
    html: `<p>Welcome, ${user.name}.</p>`,
});

Your routes and jobs call the exports

A route your apps call is part of your API, so it lives with your routes and calls the plugin. A contact form:

src/routes/contact.ts
import { EmailSchema } from '@/plugins/email/types';

export const contact = k.routes.contact({
    send: k
        .route({
            method: 'POST',
            path: '/contact',
            auth: false,
            body: EmailSchema.pick({
                subject: true,
                html: true,
            }),
            responses: {
                204: z.void(),
            },
        })
        .handler(async ({ body, plugins }) => {
            await plugins.email.send({
                to: 'support@example.com',
                ...body,
            });

            return {
                status: 204,
            };
        }),
});

The path, the body, auth and which clients get the route are yours to choose, and the plugin needs no option for any of it.

The same goes for background work. To send with retries, queue a job of your own that calls the plugin, so you decide what's queued, when and how often it retries:

src/jobs.ts
sendWelcomeEmail: k
    .job({
        input: z.object({
            userId: z.string(),
        }),
        retry: 5,
    })
    .handler(async ({ input, plugins }) => {
        const user = await db.users.find(input.userId);

        await plugins.email.send({
            to: user.email,
            subject: 'Welcome',
            html: `<p>Welcome, ${user.name}.</p>`,
        });
    }),

Routes

A plugin brings routes of its own only when something outside your apps calls them, like a service's webhooks. Declare them with route from kizunajs/plugin, the plugin's k.route, and return them from setup. They run through the same pipeline as yours, with the same validation, guards and problem details.

  • Every plugin route is hidden. It's served, and stays out of your generated clients, the OpenAPI document and MCP, since no app calls it.
  • auth: false for a caller with no credentials. A route that needs a caller takes auth as an option, because the plugin never knows your identities: RouteAuthSchema from kizunajs/plugin accepts exactly what a route's auth does.
  • path as an option on a plugin with one route, so an app can move it when it clashes with one of its own. RoutePathSchema from kizunajs/plugin accepts a path that starts with /.
  • rawBody: true on a webhook route to check the sender's signature.

If a plugin claims a path your routes already use, defineConfig throws and names both.

Base path

A plugin with several routes declares a basePath, and writes each route's path relative to it. A route at / serves the base path itself:

src/plugins/webhooks/index.ts
export const webhooksPlugin = definePlugin({
    slug: 'webhooks',
    basePath: '/webhooks',
    setup: () => ({
        routes: {
            receive: route({
                method: 'POST',
                path: '/',
                auth: false,
                responses: {
                    204: z.void(),
                },
            }).handler(receiveEvent),
            status: route({
                method: 'GET',
                path: '/status',
                auth: false,
                responses: {
                    200: StatusSchema,
                },
            }).handler(reportStatus),
        },
    }),
});

That serves /webhooks and /webhooks/status. When those clash with your own routes, move them all at install:

kizuna.config.ts
plugins: [
    webhooksPlugin({
        basePath: '/integrations',
    }),
],

Only a plugin that declares a basePath takes one at install.

Running the app's own functions

A plugin whose route runs code the app writes, like a webhook's event functions, takes those functions as an option and hands them the app's jobs and plugins. ApiContext<Config> from kizunajs/plugin types them from the app's Config, so a helper that takes Config as a type parameter gives the app typed jobs and plugins in a file of its own:

src/plugins/webhooks/events.ts
import type { ApiContext } from 'kizunajs/plugin';

export type WebhookEventHandler<Config = unknown> = (context: ApiContext<Config> & { event: WebhookEvent }) => Promise<void> | void;

export const defineWebhookEvents = <Config = unknown>(handlers: Record<string, WebhookEventHandler<Config>>) => handlers;

The route's handler passes jobs and plugins on from its own arguments. @kizunajs/resend does this for Resend's events.

Slugs

A plugin's slug is what handlers reach it under. The plugin picks one, and the app can change it. When two plugins want the same slug, defineConfig throws, names both, and tells you to rename one:

kizuna.config.ts
plugins: [
    pushNotificationsPlugin(),
    webPushPlugin({
        slug: 'webPush',
    }),
],

Handlers then use plugins.push and plugins.webPush. A plugin that needs several configurations takes them in its own options.

Publishing

A published plugin is the same module in its own package, with its reach declared:

package.json
{
    "exports": {
        ".": "./dist/index.mjs"
    },
    "kizuna": {
        "entries": {
            ".": "server"
        }
    }
}

A plugin is always server, since it runs where the config does.

Reference