Integration
Compose procedures, events, adapters, environment values, and WordPress requirements.
An integration is a standalone block that connects Kizlo to a framework, provider, or feature. It can
contribute procedures, events, adapters, environment values,
and WordPress requirements. When it has procedures, its id becomes their namespace on the client.
An integration can be maintained by Kizlo, built by the community, or written by you. All are registered the same way, with the matching companion plugin active in WordPress when one is required.
Your first integration
Here's a complete integration. It reads a WordPress post through the WordPress client at
context.wordpress and returns a shape that's yours, not WordPress's:
import { createIntegration, createProcedure } from "kizlo"
import { z } from "zod"
export const featured = () =>
createIntegration({
id: "featured",
procedures: {
get: createProcedure(
{
scope: "api",
method: "GET",
path: "/featured/{id}",
params: z.object({ id: z.coerce.number() }),
output: z.object({ id: z.number(), headline: z.string() }),
},
async ({ input, context }) => {
const { data, error } = await context.wordpress.postTypes.post.retrieve({
identifier: String(input.params.id),
})
if (error) throw error
return { id: data.id, headline: data.title.rendered }
},
),
},
})Register it on your server:
import { featured } from "./featured"
export const { procedures, client, handler } = createKizlo({
integrations: [featured()],
})That's a working integration. client.featured.get(...) is now on the client, typed from the procedure's
output. The rest of this page breaks down each piece: an integration's anatomy,
the procedures inside it, and the events it can react to.
Anatomy of an integration
createIntegration takes an id and any contributions the provider owns:
createIntegration({
id: "featured", // → client.featured
procedures: { get, list },
events: [/* webhook handlers */],
adapters: { auth },
env: {
baseUrl: process.env.NEXT_PUBLIC_KIZLO_BASE_URL,
mode: process.env.KIZLO_MODE,
remote: {
siteSecret: process.env.KIZLO_WP_SECRET,
wordpressUrl: process.env.KIZLO_WP_URL,
wordpressUsername: process.env.KIZLO_WP_USERNAME,
wordpressPassword: process.env.KIZLO_WP_APP_PASSWORD,
},
local: {
siteSecret: process.env.KIZLO_LOCAL_WP_SECRET,
wordpressUrl: process.env.KIZLO_LOCAL_WP_URL,
wordpressUsername: process.env.KIZLO_LOCAL_WP_USERNAME,
wordpressPassword: process.env.KIZLO_LOCAL_WP_APP_PASSWORD,
},
providerRegion: process.env.PROVIDER_REGION,
},
requires: {
env: ["providerRegion"],
plugins: [{ name: "provider-plugin", version: "1.2.0" }],
endpoints: ["provider.profile"],
},
})Every contribution is optional:
procedures: procedures or nested groups mounted belowid. An absent or empty tree adds no namespace.events: webhook handlers the integration reacts to.adapters: auth, captcha, geo, logger, or cookies implementations. Later integrations replace earlier adapter values; see Which adapter wins?.env: runtime-neutral Kizlo connection values plus any provider-specific values. Later concrete values replace earlier leaf values;undefinedis ignored. WordPress values live underremoteandlocal. Kizlo never reads a runtime environment directly.requires: environment values and generated endpoint subtrees checked when the server starts, plus companion plugin versions checked from WordPress response headers. Environment requirements accept dotted paths such asremote.wordpressUrland run after every integration'senvcontribution is composed.
This shape lets a provider keep its SDK and adapter setup inside the integration factory. The consumer imports one integration instead of coordinating a separate adapter configuration.
Frameworks use the same shape. Import createKizlo from kizlo, then register the framework integration.
It only maps the framework's environment onto camel-case values. Kizlo selects the local or remote WordPress
values after every integration is composed:
import { createKizlo } from "kizlo"
import { nextjs } from "kizlo/nextjs/server"
export const { procedures, client, handler } = createKizlo({
integrations: [nextjs(), provider()],
})Procedures
A procedure is a single operation that lives inside an integration's procedures, the only place you ever
author one. Its scope decides whether it's exposed as an HTTP endpoint or stays a server-only
function. You create it with createProcedure, which takes an options object describing the operation
and a handler that runs it:
import { createProcedure } from "kizlo"
import { z } from "zod"
createProcedure(
{
scope: "api", // where the procedure can be called from
method: "GET",
path: "/featured/{id}",
params: z.object({ id: z.coerce.number() }),
output: z.object({ id: z.number(), headline: z.string() }),
},
async ({ input, context, errors }) => {
// ...build and return a value matching `output`
},
)Scope
Every procedure declares a scope. It decides where the procedure can be called from, and for api
versus remote, what shape the client gets back.
api: a REST-style HTTP endpoint, defined withmethod+pathand typedparams/query/body/headers. Callable from the server, the browser, and any external HTTP client.remote: an RPC-style endpoint, defined with a singleinputschema (and optionalmethod). Callable from the server and browser clients.internal: server-only. Left out of the browser client, and rejected at runtime if called there. The built-inseoprocedures use this.
api vs remote
Both travel over HTTP as JSON, but the return types differ:
apireturns plain JSON, so the output stays serialized: aDatearrives as astring, and the client types match.remotedeserializes the response back to your original types, so aDatestays aDate, at runtime and in the type signature.
So reach for remote when you want rich types end to end, and api for endpoints external services call,
like a Stripe webhook.
Input & output
The schemas you declare in the options become the procedure's contract:
apimergesparams,query,body, andheadersinto the typedinputyour handler receives.remoteandinternaltake a singleinputschema instead.outputis always required and types the return value. Change it and every call site updates with it.
Every schema slot speaks Standard Schema, so use any compliant library like
Zod, Valibot, or ArkType. Or use none: pass
schemaType<MyType>() to type a slot from a plain TypeScript type with no runtime validation. Either way
the types flow through to the handler and the client.
Handler
The handler receives { input, context, errors }:
input: the validated, typed request, shaped by your input schemas.context: server-side services and adapters (the WordPress client, logger, and more), plus any properties added by the procedure's middleware, all fully typed.errors: the procedure's typed error map. Throwerrors.SOMETHING()for a known failure. See Errors below.
Errors
A procedure declares the failures it can return up front, so the codes are typed end to end: in the
handler when you throw them, and on the client when you check error.code.
The errors option takes a plain object. Each entry is a code with a status, a default message, and an
optional data schema for a typed payload. For codes used by a single procedure, declare them inline:
createProcedure(
{
scope: "api",
method: "GET",
path: "/featured/{id}",
params: z.object({ id: z.coerce.number() }),
output: z.object({ id: z.number(), headline: z.string() }),
errors: {
FEATURED_NOT_FOUND: { status: 404, message: "Featured item not found" },
FEATURED_EXPIRED: { status: 410, message: "This feature has expired" },
},
},
async ({ errors }) => {
throw errors.FEATURED_NOT_FOUND()
},
)That inline object is all a single procedure needs. To share the same codes across procedures and
middlewares, pull them into a map with defineErrorMap. Define it once, then pass it to each errors
option so they all throw and type the codes identically.
import { defineErrorMap } from "kizlo"
export const FEATURED_ERROR_MAP = defineErrorMap({
FEATURED_NOT_FOUND: { status: 404, message: "Featured item not found" },
FEATURED_EXPIRED: { status: 410, message: "This feature has expired" },
})import { FEATURED_ERROR_MAP } from "./error"
// reused by the list and get procedures, and any middleware that guards them
createProcedure({ /* ... */ scope: "api", errors: FEATURED_ERROR_MAP }, async ({ errors }) => { /* ... */ })Inside the handler, the errors argument is a callable map built from that definition. Call a code to get a
typed KizloError and throw it; override the message/status/data per call if you need to:
createProcedure(
{
scope: "api",
method: "GET",
path: "/featured/{id}",
params: z.object({ id: z.coerce.number() }),
output: z.object({ id: z.number(), headline: z.string() }),
errors: FEATURED_ERROR_MAP,
},
async ({ input, context, errors }) => {
const response = await context.wordpress.postTypes.post.retrieve({
identifier: String(input.params.id),
})
if (response.error) {
switch (response.error.code) {
case "rest_post_invalid_id":
throw errors.FEATURED_NOT_FOUND()
default:
// unknown upstream failure: log it and return a generic 500
context.logger.error("Get featured unhandled error", response.error)
throw errors.INTERNAL_SERVER_ERROR()
}
}
return { id: response.data.id, headline: response.data.title.rendered }
},
)The common HTTP errors (BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND,
INTERNAL_SERVER_ERROR, and the rest) are always on errors without being declared. Your map only
adds the codes specific to this procedure.
Inside a handler, Kizlo hands you the errors map ready to use. To get that same callable map outside a
procedure, in a shared helper that should throw the same typed codes, build one with
createThrowableErrorMap. Pass it a defined map and you get back the exact errors object a handler
has, common errors included:
import { createThrowableErrorMap } from "kizlo"
import { FEATURED_ERROR_MAP } from "./error"
const errors = createThrowableErrorMap(FEATURED_ERROR_MAP)
export function assertFeatured(item: Item | null): asserts item is Item {
if (!item) throw errors.FEATURED_NOT_FOUND()
if (item.expired) throw errors.FEATURED_EXPIRED() // your codes
// errors.NOT_FOUND(), errors.BAD_REQUEST(), ... // common codes too
}Call assertFeatured(item) from any procedure handler and the thrown KizloError carries the same typed
code your procedures declare.
Middleware
Run shared logic before a procedure's handler, like auth checks, rate limiting, or loading a record, with
middleware. Create one with createMiddleware and attach it through the procedure's middlewares option:
import { createMiddleware, createProcedure } from "kizlo"
const requireMember = createMiddleware(async ({ context, next, errors }) => {
const user = await context.getAuthUser()
if (!user) throw errors.UNAUTHORIZED()
// pass control on, extending the context for the handler
return next({ context: { userId: user.id } })
})
createProcedure(
{
scope: "api",
method: "GET",
path: "/featured/{id}",
params: z.object({ id: z.coerce.number() }),
output: z.object({ id: z.number(), headline: z.string() }),
middlewares: [requireMember],
},
async ({ input, context }) => {
context.userId // ✅ added by requireMember, fully typed
},
)A middleware receives the same { input, context, errors } as a handler plus a next function, and:
- must call
next()to pass control down the chain, then return its result. Skip the call (e.g. by throwing) to short-circuit before the handler ever runs. - can extend the context by passing
next({ context: { ... } }). Whatever you add is merged intocontextand typed for every middleware and the handler after it. That's howcontext.userIdabove becomes available.
Because next() resolves only after the handler (and any later middleware) finishes, awaiting it lets a
single middleware run logic on both sides, before and after the procedure:
const timing = createMiddleware(async ({ context, next }) => {
const start = Date.now() // before
const result = await next() // runs the handler
context.logger.info("procedure done", { ms: Date.now() - start }) // after
return result
})Middlewares run in the order listed, so put broad guards (auth) before narrower ones that depend on them.
Events
Events let an integration react to things happening in WordPress: a post published, a term deleted, settings saved. WordPress sends these as signed webhooks, and Kizlo dispatches each one to the handlers your integrations registered.
Register them through the integration's events array, building each with createEventHandler:
import { createEventHandler, createIntegration } from "kizlo"
const onContentChange = createEventHandler(async (event, context) => {
if (!event) return
switch (event.type) {
case "post.updated": // event.data → { post_id, post_type }
await revalidatePath(`/posts/${event.data.post_id}`)
break
case "term.deleted": // event.data → { term_id, taxonomy, post_types, count }
context.logger.info("Term removed", { id: event.data.term_id })
break
}
})
export const cache = () =>
createIntegration({
id: "cache",
events: [onContentChange],
})The handler receives the { type, data } event and the server context, and returns nothing. Events are
fire-and-forget:
- They run server-side after WordPress notifies Kizlo.
- A handler that throws is caught and logged, never surfaced to a caller.
- With no declared types, a handler is invoked for every built-in event, so switch on
event.typeto pick the ones you care about.
Built-in events
The Kizlo plugin ships these events out of the box:
| Event types | data |
|---|---|
post.created post.updated post.trashed post.deleted | { post_id, post_type } |
term.created term.updated term.deleted | { term_id, taxonomy, post_types, count } |
settings.site.updated settings.brand.updated settings.identity.updated settings.authors.updated settings.crawling.updated settings.integration.updated | null |
settings.post_type.updated settings.taxonomy.updated | { key } |
Here post means WordPress's post type, not just blog posts. Most content, from pages to custom post
types, is a post type under the hood, so post.* events fire for all of them. Check event.data.post_type
to tell which.
Custom events
Your own plugin can emit its own events for your integration to handle, the same one-way flow of WordPress → Kizlo. There are two sides.
Emit from WordPress. The Kizlo plugin exposes a global kizlo_emit_event($type, $data) helper. Call it from
your plugin wherever the thing happens, and the $data array becomes the event's payload:
kizlo_emit_event('review.created', [
'review_id' => $review->id,
'rating' => $review->rating,
]);Handle in your integration. Declare the event's type and a matching data schema as the first argument to
createEventHandler, and the handler is then typed to that union:
import { createEventHandler } from "kizlo"
import { z } from "zod"
const onReview = createEventHandler(
[{ types: ["review.created"], data: z.object({ review_id: z.number(), rating: z.number() }) }],
async (event, context) => {
if (event?.type !== "review.created") return
context.logger.info("New review", { rating: event.data.rating }) // typed
},
)Prebuilt integrations
Not every integration is one you write. Kizlo and the community publish integrations as npm packages. Install one, register it exactly like your own, and its namespace lands on the client:
npm install @kizlo/example-integrationimport { exampleIntegration } from "@kizlo/example-integration"
export const { client, handler } = createKizlo({
integrations: [exampleIntegration()],
})Now client.exampleIntegration.* is on the client, typed the same as the core namespaces. A published
integration that bridges a third-party WordPress plugin also ships a companion plugin you activate in
WordPress, covered next.
Companion plugins
Everything above is the app side of an integration. Some integrations also have a WordPress side: when an integration bridges a third-party WordPress plugin, it ships with a matching companion plugin that makes that plugin reachable, and can add features on top.
When a companion plugin is needed, and why the WordPress side splits into the required Kizlo plugin plus optional companions, is covered under Plugin.