Plugin Interface
SlingshotPlugin is the framework-level plugin authoring contract. Use it for auth,
persistence, queues, search, and other infrastructure that needs async bootstrap, registrar
registrations, or conditional middleware. It’s a parallel first-class authoring tier to
definePackage — pick by the shape of what
you’re building, not by perceived level. See
Composing an App for the decision guide.
The type lives in @lastshotlabs/slingshot-core.
The interface
Section titled “The interface”import type { Hono } from 'hono';import type { PluginSetupContext } from '@lastshotlabs/slingshot-core';
interface SlingshotPluginShape { name: string; dependencies?: string[]; tenantExemptPaths?: string[]; csrfExemptPaths?: string[]; publicPaths?: string[];
setupMiddleware?(ctx: PluginSetupContext): void | Promise<void>; setupRoutes?(ctx: PluginSetupContext): void | Promise<void>; setupPost?(ctx: PluginSetupContext): void | Promise<void>; setup?(ctx: PluginSetupContext): void | Promise<void>; teardown?(): void | Promise<void>;}
type StandaloneApp = Hono;void (0 as unknown as StandaloneApp);A complete plugin example
Section titled “A complete plugin example”import { type SlingshotPlugin, defineEvent, getActor, getContext, publishPluginState,} from '@lastshotlabs/slingshot-core';
type Note = { id: string; text: string;};
type NotesAdapter = { list(userId: string | null): Promise<Note[]>; deleteAll(userId: string): Promise<void>;};
export interface NotesPluginConfig { maxNotesPerUser: number;}
declare module '@lastshotlabs/slingshot-core' { interface SlingshotEventMap { 'note.created': { userId: string; noteId: string }; 'note.deleted': { userId: string; noteId: string }; }}
export function createNotesPlugin(config: NotesPluginConfig): SlingshotPlugin { return { name: 'notes', dependencies: ['slingshot-auth'],
setupRoutes({ app }) { app.get('/notes', async c => { const ctx = getContext(app); const adapter = ctx.pluginState.get('notes.adapter') as NotesAdapter | undefined;
if (!adapter) { return c.json({ error: 'Notes adapter not initialized' }, 500); }
const actor = getActor(c); const notes = await adapter.list(actor.kind !== 'anonymous' ? actor.id : null); return c.json(notes, 200); }); },
setupPost({ app, bus, events }) { const ctx = getContext(app); const adapter = ctx.pluginState.get('notes.adapter') as NotesAdapter | undefined;
publishPluginState(ctx.pluginState, 'notes.config', config);
bus.on('auth:user.deleted', async ({ userId }) => { if (adapter) { await adapter.deleteAll(userId); } });
events.register( defineEvent('note.created', { ownerPlugin: 'notes', exposure: ['client-safe'], resolveScope(payload) { return { userId: payload.userId, actorId: payload.userId }; }, }), ); events.register( defineEvent('note.deleted', { ownerPlugin: 'notes', exposure: ['client-safe'], resolveScope(payload) { return { userId: payload.userId, actorId: payload.userId }; }, }), ); },
teardown() { // Close plugin-owned resources here when needed. }, };}Lifecycle phases
Section titled “Lifecycle phases”The framework runs three phases in order across all plugins - finishing every plugin’s setupMiddleware before starting any plugin’s setupRoutes. Order within a phase follows your dependencies declaration.
The framework validates dependency presence and cross-phase ordering before boot. A plugin that only defines setupRoutes cannot safely depend on a plugin whose first active phase is setupPost, and standalone-only plugins that define only setup() are skipped by framework-managed boot entirely.
setupMiddleware - global middleware
Section titled “setupMiddleware - global middleware”Runs before any route is matched. Mount middleware that must intercept every request: JWT verification, CSRF validation, tenant identification, request guards.
The auth plugin mounts userAuth here so it is available across all subsequent route files, regardless of which plugin registered those routes.
setupRoutes - mount your routes
Section titled “setupRoutes - mount your routes”Runs after setupMiddleware finishes on all plugins, and after tenant middleware. Mount your Hono routers, register OpenAPI routes, and do any setup that needs tenant context.
setupPost - wire up the fully assembled app
Section titled “setupPost - wire up the fully assembled app”Runs after all routes across all plugins are registered. The app is fully assembled at this point. Use it for:
- Subscribing to event bus events
- Reading state other plugins published through
pluginState - Cross-plugin capability registration such as permissions, channels, or search sync
If the root app or server config includes ws, Slingshot also seeds getContext(app).wsEndpoints
before setupPost runs. That lets a plugin attach incoming, onRoomSubscribe, or other
endpoint hooks during bootstrap, and createServer() later starts from that same endpoint map.
Declare the endpoint path in the root ws.endpoints config first, then mutate that draft in
setupPost rather than inventing ad hoc endpoint keys inside the plugin.
The root ws config uses the same WsConfig shape whether you call createApp() or
createServer(), so these bootstrap mutations stay type-checked even when the app never starts a
live transport in the current process.
createApp() carries that root ws draft only far enough for plugin lifecycle wiring and context
assembly. createServer() reuses the same typed draft later when it owns transport startup.
teardown - graceful shutdown
Section titled “teardown - graceful shutdown”Called on SIGTERM and SIGINT. Close database connections, cancel background timers, and flush queues.
Dependency ordering
Section titled “Dependency ordering”export function createNotesPlugin(config: NotesPluginConfig): SlingshotPlugin { return { name: 'notes', dependencies: ['slingshot-auth', 'slingshot-permissions'], };}Slingshot validates and topologically sorts plugins before running the lifecycle. If slingshot-auth has not booted yet when notes tries to read from ctx.pluginState, bootstrap fails instead of silently producing undefined.
Declare everything you depend on. Leave off what you do not.
Publishing state to other plugins
Section titled “Publishing state to other plugins”Use pluginState to make your plugin’s runtime available to sibling plugins:
type NotesAdapter = { list(userId: string | null): Promise<unknown[]>;};
declare function createNotesAdapter(): NotesAdapter;declare const config: { maxNotesPerUser: number };
setupRoutes({ app }) { const ctx = getContext(app);
publishPluginState(ctx.pluginState, 'notes.adapter', createNotesAdapter()); publishPluginState(ctx.pluginState, 'notes.config', config);}Another plugin reads it:
type NotesAdapter = { list(userId: string | null): Promise<unknown[]>;};
setupPost({ app }) { const ctx = getContext(app); const notesAdapter = ctx.pluginState.get('notes.adapter') as NotesAdapter | undefined; if (notesAdapter) { void notesAdapter; }}Stable capability identity
Section titled “Stable capability identity”Capability resolvers must return a stable reference across every lifecycle phase. The
framework calls provider.resolve() twice per package (once in setupMiddleware and
once in setupPost) and republishes the slot each time; if you return a fresh object
on each call, downstream consumers that captured the cap in setupMiddleware will
observe !== against the same cap resolved in setupPost. The canonical pattern is
to construct a long-lived Proxy once per package instance and have it defer to a
closure-owned stateRef that gets populated as adapters become available — see
packages/slingshot-interactions/src/plugin.ts and
packages/slingshot-orchestration/src/plugin.ts for representative
implementations.
Standalone setup()
Section titled “Standalone setup()”The framework never calls setup(). It exists for packages that also support a plain Hono integration outside createApp():
import { Hono } from 'hono';import { type PluginSetupContext, type SlingshotPlugin, createEventDefinitionRegistry, createEventPublisher, createInProcessAdapter,} from '@lastshotlabs/slingshot-core';
declare const notesPlugin: SlingshotPlugin;
const app = new Hono() as PluginSetupContext['app'];const config = {} as PluginSetupContext['config'];const bus = createInProcessAdapter();const events = createEventPublisher({ definitions: createEventDefinitionRegistry(), bus,});
await notesPlugin.setup?.({ app, config, bus, events });Framework usage is different: the framework calls setupMiddleware, setupRoutes, and setupPost in order, and it never calls setup().
When the framework does call lifecycle methods, it invokes them on the plugin instance itself. That means plugin authors can safely read closure-owned config or instance state from this if they deliberately choose method syntax, although most Slingshot plugins still prefer closure-owned factories over instance mutation.
See also
Section titled “See also”- SlingshotContext - reading and writing instance-scoped runtime state
- CoreRegistrar - the registrar contract and what it publishes
- Event Bus - emitting and subscribing to events
- Internals: Plugin Lifecycle - deep dive into boot ordering and phase execution
- Observability & Distributed Tracing - plugin lifecycle spans and
createChildSpanfor request-level tracing