Building a Custom Plugin
Plugins extend Slingshot with your own routes, state, and event behavior. They are now the
advanced/escape-hatch authoring surface. For standard packages, prefer
definePackage(...), entity(...), domain(...), and typed capabilities first. Use a raw plugin
when you need framework-lifecycle control, external integration bootstrapping, or behavior that
does not fit the package/domain/entity shell. The example below builds a product catalog plugin
from scratch.
What you’re building
Section titled “What you’re building”A catalog plugin that:
- Mounts
GET /catalog/productsandPOST /catalog/products - Publishes
entity:products.createdwhen a product is added - Requires the caller to be authenticated
- Stores its state in
pluginStatefor other plugins to read
The plugin structure
Section titled “The plugin structure”src/plugins/catalog/ index.ts ← createCatalogPlugin() — the factory router.ts ← HTTP routes store.ts ← in-memory store (swap for a DB later) types.ts ← shared typesStep 1: Define the store
Section titled “Step 1: Define the store”The store is a plain factory — closure-owned state, no singletons:
export interface Product { id: string; name: string; price: number; createdAt: string;}
export function createProductStore() { const products = new Map<string, Product>();
return { list(): Product[] { return Array.from(products.values()); }, get(id: string): Product | undefined { return products.get(id); }, create(data: { name: string; price: number }): Product { const product: Product = { id: crypto.randomUUID(), name: data.name, price: data.price, createdAt: new Date().toISOString(), }; products.set(product.id, product); return product; }, };}
export type ProductStore = ReturnType<typeof createProductStore>;Step 2: Mount the routes
Section titled “Step 2: Mount the routes”The router receives the store and registry-backed event publisher — no globals, no imports from outer scope:
import { createRoute, createRouter, errorResponse, getActorId } from '@lastshotlabs/slingshot-core';import type { SlingshotEvents } from '@lastshotlabs/slingshot-core';import type { Product, ProductStore } from './store';
declare module '@lastshotlabs/slingshot-core' { interface SlingshotEventMap { 'entity:products.created': { product: Product; userId: string }; }}
export function createCatalogRouter(store: ProductStore, events: SlingshotEvents) { const router = createRouter();
router.openapi( createRoute({ method: 'get', path: '/catalog/products', tags: ['Catalog'], responses: { 200: { description: 'List of products', content: { 'application/json': { schema: { type: 'object', properties: { products: { type: 'array', items: { type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' }, price: { type: 'number' }, createdAt: { type: 'string' }, }, required: ['id', 'name', 'price', 'createdAt'], }, }, }, required: ['products'], }, }, }, }, }, }), async c => { return c.json({ products: store.list() }) as never; }, );
router.openapi( createRoute({ method: 'post', path: '/catalog/products', tags: ['Catalog'], request: { body: { content: { 'application/json': { schema: { type: 'object', properties: { name: { type: 'string', minLength: 1 }, price: { type: 'number', exclusiveMinimum: 0 }, }, required: ['name', 'price'], }, }, }, }, }, responses: { 201: { description: 'Created product', content: { 'application/json': { schema: { type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' }, price: { type: 'number' }, createdAt: { type: 'string' }, }, required: ['id', 'name', 'price', 'createdAt'], }, }, }, }, 401: { description: 'Not authenticated' }, }, }), async c => { const userId = getActorId(c); if (!userId) return errorResponse(c, 'Unauthorized', 401);
const body = (await c.req.json()) as { name: string; price: number }; const product = store.create(body);
// Emit to the event bus — other plugins and SSE clients can subscribe events.publish( 'entity:products.created', { product, userId }, { userId, actorId: userId, source: 'http', requestTenantId: null }, );
return c.json(product, 201) as never; }, );
return router;}Step 3: Implement the plugin lifecycle
Section titled “Step 3: Implement the plugin lifecycle”A plugin runs in four phases. setupMiddleware fires first (before tenant middleware). setupRoutes fires after tenant middleware — handlers can read the resolved actor and other request context values. setupPost fires last, after all routes register — use it for subscriptions and other true post-assembly work. pluginState is not a setupPost-only concept: publish it in the earliest phase where the state is actually canonical.
import { type SlingshotPlugin, defineEvent, getContext, publishPluginState,} from '@lastshotlabs/slingshot-core';import { createCatalogRouter } from './router';import { createProductStore } from './store';
export function createCatalogPlugin(): SlingshotPlugin { // Store is created once per plugin instance — no shared state const store = createProductStore();
return { name: 'catalog', dependencies: ['slingshot-auth'], // auth must run before catalog
async setupMiddleware({ events }) { events.register( defineEvent('entity:products.created', { ownerPlugin: 'catalog', exposure: ['client-safe'], resolveScope: (_payload, ctx) => ({ userId: ctx.userId ?? null, actorId: ctx.actorId ?? null, }), }), ); },
async setupRoutes({ app, events }) { // Routes are mounted after tenant middleware — they get the resolved actor on context const ctx = getContext(app); publishPluginState(ctx.pluginState, 'catalog', { store });
app.route('/', createCatalogRouter(store, events)); },
async setupPost({ bus }) { // Subscribe to product events — log them for now bus.on('entity:products.created', async ({ product }) => { console.log(`Product created: ${product.name} ($${product.price})`); }); }, };}Step 4: Wire it into the app
Section titled “Step 4: Wire it into the app”// @skip-typecheckimport { defineApp } from '@lastshotlabs/slingshot';import { createAuthPlugin } from '@lastshotlabs/slingshot-auth';import { createCatalogPlugin } from './plugins/catalog';
export default defineApp({ port: 3000, security: { signing: { secret: process.env.JWT_SECRET! }, }, plugins: [ createAuthPlugin({ auth: { roles: ['user'], defaultRole: 'user' }, db: { auth: 'sqlite', sessions: 'sqlite', oauthState: 'sqlite' }, }), createCatalogPlugin(), ],});The routes you get
Section titled “The routes you get”# List products (no auth required)curl http://localhost:3000/catalog/products
# Create a product (requires auth)curl -X POST http://localhost:3000/catalog/products \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer <jwt>' \ -d '{ "name": "Widget", "price": 9.99 }'Add realtime updates
Section titled “Add realtime updates”entity:products.created is already registered as a client-safe event. Add an SSE endpoint and browser clients receive it automatically:
{ "sse": { "endpoints": { "/__sse": { "auth": true } } }}// Browser: subscribe to catalog eventsconst es = new EventSource('/__sse', { withCredentials: true });es.addEventListener('entity:products.created', e => { const { product } = JSON.parse(e.data); console.log('New product:', product.name);});If you also need a plugin-owned WebSocket hook, declare the endpoint in the root ws.endpoints
config and then patch the seeded draft during setupPost:
import { getContext } from '@lastshotlabs/slingshot-core';
const plugin = { async setupPost({ app }: { app: object }) { const ctx = getContext(app); const endpoint = ctx.wsEndpoints?.['/__ws/catalog']; if (!endpoint) return;
endpoint.incoming = { 'catalog:ping': { handler: async (ws, _payload, _context) => { const socket = ws as { send(message: string): void }; socket.send(JSON.stringify({ event: 'catalog:pong' })); }, }, }; },};
void plugin;That draft is the same endpoint map createServer() later boots, so the handler is live once the
server starts.
createApp() also seeds the same draft during tests and non-server bootstrap, so plugin WebSocket
registration code does not need a separate path outside live server startup.
Read catalog state from another plugin
Section titled “Read catalog state from another plugin”Cross-plugin communication goes through pluginState. If catalog isn’t installed, the check returns undefined — no hard dependency. Publish state when it becomes canonical, then read it from a later phase or a request handler:
import { getContext } from '@lastshotlabs/slingshot-core';
interface ProductStore { list(): Array<{ id: string; name: string; price: number; createdAt: string }>;}
// In another plugin's setupRoutes, setupPost, or a route handler:declare const app: object;
const ctx = getContext(app);const catalogState = ctx.pluginState.get('catalog') as { store: ProductStore } | undefined;if (catalogState) { const products = catalogState.store.list();}Framework note
Section titled “Framework note”Framework-managed plugins should put real work in setupMiddleware, setupRoutes, and setupPost. The optional setup() hook is only a standalone convenience for plain Hono integration and is not part of the createApp() or createServer() lifecycle.
Next steps
Section titled “Next steps”- Add a Postgres or SQLite adapter to replace the in-memory store — see Entity System: Storage and Adapter Wiring
- Publish the plugin as an npm package — see Publishing
- Move package-owned behavior onto the canonical package surface — see Package-First Authoring
- Add observability to your plugin with
createChildSpan— see Observability & Distributed Tracing