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:
Write it
Install it
Use it
Every route handler and every job handler receives it under plugins, typed:
definePlugin
| Field | What it gives the plugin |
|---|---|
slug | What handlers reach it under: plugins.email |
options | A Zod schema for what the app passes in |
setup | Builds the plugin from its options and returns what it brings |
setup returns any of these:
| Key | What it is |
|---|---|
exports | Functions every route and job handler reaches at plugins.<name> |
routes | Routes an outside system calls, like a webhook |
generators | Files 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.
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:
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:
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:
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:
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:
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: falsefor a caller with no credentials. A route that needs a caller takesauthas an option, because the plugin never knows your identities:RouteAuthSchemafromkizunajs/pluginaccepts exactly what a route'sauthdoes.pathas an option on a plugin with one route, so an app can move it when it clashes with one of its own.RoutePathSchemafromkizunajs/pluginaccepts a path that starts with/.rawBody: trueon 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:
That serves /webhooks and /webhooks/status. When those clash with your own routes, move them all at install:
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:
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:
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:
A plugin is always server, since it runs where the config does.
Reference
- Config for installing plugins
mcpPluginandopenApiPluginfor worked examples- Create an Adapter if the runtime itself is missing