-
Notifications
You must be signed in to change notification settings - Fork 0
plugin development guide
This guide walks through creating a SlopSmith plugin from scratch. Plugins live in the user plugin directory and are discovered automatically at server start.
A plugin is a folder containing at minimum a plugin.json manifest:
my-plugin/
├── plugin.json # required — manifest
├── server.ts # optional — backend module
├── component.vue # optional — frontend Vue SFC
├── screen.html # optional — fallback HTML view
└── settings.html # optional — settings page HTML
Place the folder in the configured user plugins directory (default: ~/.slopsmith/plugins/).
Only id, name, and version are required. Add only the fields your plugin uses.
Export setup and optionally teardown. The runtime calls these during lifecycle transitions.
// server.ts
import type { PluginModule } from '@slopsmith/plugin-types'
export const plugin: PluginModule = {
async setup(ctx) {
// Register an HTTP route
ctx.routes.register('GET', '/hello', async (req, reply) => {
return { message: 'Hello from my-plugin!' }
})
// Subscribe to a hook
ctx.hooks.on('song:play', async ({ songId }) => {
ctx.logger.info(`Song started: ${songId}`)
})
// Persist some data
await ctx.db.set('initialized', true)
},
async teardown(ctx) {
// Clean up resources — hooks and routes are removed automatically
ctx.logger.info('my-plugin unloaded')
}
}| Property | Type | Purpose |
|---|---|---|
ctx.pluginId |
string |
This plugin's ID |
ctx.pluginDir |
string |
Absolute path to the plugin folder |
ctx.config |
AppConfig |
Global app configuration |
ctx.hooks.on(event, handler) |
— | Subscribe to a lifecycle event |
ctx.hooks.once(event, handler) |
— | Subscribe once |
ctx.hooks.off(event, handler) |
— | Unsubscribe |
ctx.routes.register(method, path, handler) |
— | Register HTTP route (scoped to /api/plugins/{id}/) |
ctx.routes.ws(path, handler) |
— | Register WebSocket endpoint |
ctx.providers.register(type, name, impl) |
— | Register a provider implementation |
ctx.providers.get(type) |
— | Get the active provider |
ctx.db.get/set/delete/list(key) |
Promise |
Scoped key-value storage |
ctx.permissions.define(name, description) |
— | Declare a custom permission |
ctx.logger |
Logger |
Structured logger |
The Vue SFC can export a setup function for programmatic initialization:
<!-- component.vue -->
<template>
<div class="my-plugin">
<h2>{{ title }}</h2>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { FrontendPluginContext } from '@/plugins/FrontendPluginContext'
// The host calls this before mounting
export async function setup(ctx: FrontendPluginContext) {
// Register into a UI slot
ctx.slots.register('player-overlay', MyOverlayComponent, { order: 10 })
// Subscribe to events
ctx.events.on('song:play', ({ songId }) => {
console.log('Playing:', songId)
})
}
const title = ref('My Plugin')
</script>If setup is not needed, just ship a plain Vue SFC — the loader will import and render it without calling setup.
| Property | Purpose |
|---|---|
ctx.pluginId |
This plugin's ID |
ctx.events.on(event, handler) |
Subscribe to frontend events |
ctx.events.once(event, handler) |
Subscribe once |
ctx.events.emit(event, detail) |
Emit a custom event |
ctx.slots.register(slot, component, opts?) |
Inject a component into a named UI slot |
ctx.api.get/post/patch/delete(path, body?) |
Call this plugin's backend routes |
Providers let a plugin offer a swappable implementation of a backend service. Example: a cloud storage backend.
// server.ts
import type { IStorageProvider } from '@slopsmith/core'
export const plugin: PluginModule = {
async setup(ctx) {
const s3Storage: IStorageProvider = {
async read(path) { /* ... */ },
async write(path, data) { /* ... */ },
async delete(path) { /* ... */ },
}
ctx.providers.register('storage', 's3', s3Storage)
}
}Users switch the active provider via Settings → Providers or PUT /api/plugins/providers/storage/active.
ctx.permissions.define('manage_settings', 'Allow user to modify plugin configuration')The permission name is then referenced in route registration:
ctx.routes.register('POST', '/config', handler, {
permission: 'manage_settings'
})For simple browser-only plugins, skip the backend module entirely and point script at a plain JS/TS file:
{
"id": "my-script",
"name": "My Script Plugin",
"version": "1.0.0",
"script": "./index.js"
}// index.js — loaded as a <script> tag
window.slopsmith.on('song:play', ({ songId }) => {
console.log('Playing:', songId)
})Or export a setup() for the modern API:
export async function setup(ctx) {
ctx.events.on('song:play', ({ songId }) => {
console.log('Playing:', songId)
})
}If your plugin depends on another, declare it in the manifest. The runtime will load dependencies before your plugin:
{
"dependsOn": ["themes", "storage-s3"]
}Circular dependencies are detected at startup and will prevent all involved plugins from loading.
-
Hot reload: Disable and re-enable the plugin via
POST /api/plugins/{id}/enableto reload it without restarting the server. -
Logs: All
ctx.loggeroutput is tagged with the plugin ID — grep for your ID in the server log. -
DB inspection: Use
ctx.db.list('')to dump all stored keys for debugging. - Error isolation: Uncaught errors inside hooks are caught by the host and logged — they do not crash the server or affect other plugins.
{ "id": "my-plugin", // unique, kebab-case "name": "My Plugin", "version": "1.0.0", "type": "visualization", // visualization | provider | (omit for generic) // Backend "script": "./server.ts", // backend module with setup() export "routes": [], // declared for documentation; registered inside setup() // Frontend "component": "./component.vue", // Vue SFC path "screen": "./screen.html", // fallback if no component "settings": "./settings.html", // settings page // Navigation "nav": { "label": "My Plugin", "icon": "puzzle-piece", // any Heroicon name "order": 50 }, // Dependency ordering "dependsOn": ["other-plugin-id"], // Flags "bundled": false, // true for built-in plugins "private": false // true to hide from UI listings }