This walks through building a Kytelo plugin using @kytelo/plugin-cart — the platform's own dogfood plugin — as the worked example. It proves filters, sections, and an admin slot all flow end to end through the SDK contract in one small package.

Read the platform overview first if you haven't — this page assumes you know what a filter, event, job, and section are.

definePlugin()#

A plugin is a manifest plus the registrations that back it, sealed by definePlugin():

// plugins/cart/src/plugin.ts
export const cartPlugin = definePlugin({
  manifest: {
    name: '@kytelo/plugin-cart',
    version: '0.1.0',
    displayName: 'Shopping Cart',
    description: 'Cart drawer, cart page, pricing and line validation.',
    compatibility: '^0.1.0',
    capabilities: [
      'filters:cart.pricing',
      'filters:cart.line.validate',
      'sections:register',
      'admin:slot:product.detail.side',
      'data:carts:read',
    ],
    filters: ['cart.pricing', 'cart.line.validate'],
    sections: ['cart-drawer', 'cart-page'],
    admin: {
      slots: [
        { slot: 'product.detail.side', component: './admin/CartStatsPanel' },
      ],
      routes: [],
    },
  },
  filters: [cartPricingHandler, cartLineValidateHandler],
  sections: [cartDrawer, cartPage],
});

definePlugin() validates the manifest, then cross-checks every runtime registration against it, twice over:

  1. Declared — a filter handler for cart.pricing must appear in manifest.filters; a section must appear in manifest.sections; an admin slot component must appear in manifest.admin.slots.
  2. Permitted — the same registration also needs the matching capability string (filters:cart.pricing, admin:slot:product.detail.side, ...).

Miss either and definePlugin() throws PluginDefinitionError with every problem listed — at import time, in your editor or CI, not at boot in front of a tenant. The kernel repeats the capability check at boot and adds cross-plugin collision detection (two plugins registering the same section type, the same job queue, etc).

Capabilities exist so a plugin is reviewable from its manifest alone — "this plugin writes carts and registers one admin panel" — and so a future sandbox has something mechanical to enforce.

Filters — the cart pricing example#

cart.pricing and cart.line.validate are filter points defined once in @kytelo/plugin-sdk (createFilterPoint) with a fixed context/value shape, a timeout, and an onError policy. The cart plugin's handlers:

// plugins/cart/src/filters.ts
export const cartPricingHandler = defineFilterHandler(CART_PRICING, {
  priority: 100,
  handle: (ctx) => {
    const lines = ctx.lines.map((line) => ({
      lineId: line.lineId,
      lineTotalCents: line.unitPriceCents * line.qty,
      adjustments: [],
    }));
    const subtotalCents = lines.reduce((sum, l) => sum + l.lineTotalCents, 0);
    return { lines, subtotalCents };
  },
});

Lower priority runs first. The kernel seeds cart.pricing with an all-zero placeholder, so this handler — the only one registered anywhere in the platform today — rebuilds pricing from scratch rather than trying to reuse it. A future promotions plugin would register at a higher priority (say 200) and layer discounts onto each line's adjustments instead of recomputing base pricing, since it runs after this one.

cart.line.validate shows the fail-open/fail-closed distinction: onError: 'skip' means a crashed handler never blocks an add-to-cart, but the handler itself is written to keep a { ok: false } from an earlier-priority handler sticky (if (!value.ok) return value;) rather than silently re-approving it.

Sections — cart drawer and cart page#

Plugin sections use the exact same defineSection() from @kytelo/theme-react a theme uses (see theme development) — the SDK itself stays renderer-free; only the React implementation package knows about JSX. The cart drawer's loader resolves the priced cart for the current session:

// plugins/cart/src/lib/load-priced-cart.ts
export function loadPricedCart({ params, data }: SectionLoaderArgs) {
  const cartToken = params['cartToken'] ?? '';
  if (!cartToken) return null;
  return data.carts.getPricedByToken(cartToken);
}

cartToken arrives via params because the storefront host injects it — sections never reach for cookies or storage directly (see the theme guide's authoring constraints; the same static rules apply to plugin sections). The drawer component itself reads and writes the cart through useStorefront().actionschangeLineQty() in plugins/cart/src/lib/cart-actions.ts wraps updateCartLine/removeCartLine so stepping a line to zero removes it instead of persisting a non-positive quantity.

cart-drawer's schema sets placement: 'layout' (it renders on every page, via the theme layout's layoutSections, not inside a merchant's page document) where cart-page defaults to placement: 'template' (a merchant places it, via the cart template).

Admin slots#

The cart plugin fills product.detail.side with CartStatsPanel — the manifest declares the slot and the capability (admin:slot:product.detail.side); the component itself lives at ./admin/CartStatsPanel, a subpath export of the plugin package. Admin doesn't import that path directly — a codegen step (tools/gen-plugin-map.mts) turns every installed plugin's declared slot paths into literal lazy(() => import(...)) entries, so plugin admin UI code-splits into its own chunk and the SDK stays React-free.

Events and jobs#

The cart plugin doesn't use these — @kytelo/plugin-search is the concrete example. Events are subscriptions to platform facts, emitted after the write that caused them commits:

// plugins/search/src/lib/events.ts
export const productCreatedHandler = defineEventHandler(
  'product.created',
  (payload, ctx) => {
    ctx.enqueue('search.index', { productId: payload.productId });
  },
);

Jobs are the queued work those events (or anything else) enqueue — executed by the platform worker, never a web request:

// plugins/search/src/lib/jobs.ts
export const searchIndexJob = defineJob<{ productId: string }>({
  queue: 'search.index',
  handler: async ({ productId }, ctx) => {
    const product = await ctx.data.products.getById(productId);
    if (!product || product.status !== 'active') {
      await ctx.data.search.removeDocument(productId);
      return;
    }
    await ctx.data.search.upsertDocument({
      productId: product.id,
      title: product.title,
      body: product.description,
    });
  },
});

Both event and queue names must appear in the manifest's events.subscribes / jobs lists (and their matching events:subscribe:<name> / jobs:register capabilities), the same declared-and-permitted pattern as filters and sections.

What's next#

  • Theme development — the section contract a plugin's sections share with a theme's.
  • Local development — toggle the cart plugin off and on in a running admin to see enablement in action.