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:
- Declared — a filter handler for
cart.pricingmust appear inmanifest.filters; a section must appear inmanifest.sections; an admin slot component must appear inmanifest.admin.slots. - 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().actions —
changeLineQty() 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.