Docs

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:

src/lib/kizlo/server/featured.ts
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:

src/lib/kizlo/server/index.ts
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 below id. 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; undefined is ignored. WordPress values live under remote and local. 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 as remote.wordpressUrl and run after every integration's env contribution 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:

src/lib/kizlo/server/index.ts
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 with method + path and typed params / query / body / headers. Callable from the server, the browser, and any external HTTP client.
  • remote: an RPC-style endpoint, defined with a single input schema (and optional method). 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-in seo procedures use this.

api vs remote

Both travel over HTTP as JSON, but the return types differ:

  • api returns plain JSON, so the output stays serialized: a Date arrives as a string, and the client types match.
  • remote deserializes the response back to your original types, so a Date stays a Date, 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:

  • api merges params, query, body, and headers into the typed input your handler receives.
  • remote and internal take a single input schema instead.
  • output is 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. Throw errors.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:

src/lib/kizlo/server/featured/index.ts
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.

src/lib/kizlo/server/featured/error.ts
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" },
})
src/lib/kizlo/server/featured/index.ts
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:

src/lib/kizlo/server/featured/index.ts
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:

src/lib/kizlo/server/featured/assert.ts
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 into context and typed for every middleware and the handler after it. That's how context.userId above 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:

src/lib/kizlo/server/cache/index.ts
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.type to pick the ones you care about.

Built-in events

The Kizlo plugin ships these events out of the box:

Event typesdata
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.updatednull
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-integration
src/lib/kizlo/server/index.ts
import { 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.

On this page