Quick Start
Scaffold a plugin with the official creator:
npx selldoes create my-plugin # asks: Plugin or Theme? (installs deps)
cd my-plugin
npm run dev # local preview at http://127.0.0.1:4590/previewThe generated project includes a manifest, a sandbox entry with a working/notes API route, an optional dashboard UI, and scripts fordev, build, validate and publish(see Local Development).
Or build it manually
1. Create the plugin directory
mkdir my-plugin
cd my-plugin2. Create plugin.json (manifest)
{
"slug": "my-plugin",
"name": "My Plugin",
"description": "A custom Selldoes plugin",
"version": "1.0.0",
"author": "Your Name",
"icon": "puzzle",
"permissions": ["db:read"],
"allowedTables": [],
"entry": "./index.js",
"dashboardPages": [],
"apiRoutes": [],
"hooks": {},
"dependencies": {},
"tags": ["example"]
}3. Create index.js (entry point)
// my-plugin/index.js
module.exports = {
init(ctx) {
console.log("Plugin loaded for store", ctx.storeId);
},
destroy() {
console.log("Plugin unloaded");
},
};Then either install it from the dashboard (Plugins → Developer when running the app with PLUGIN_DEV_DIR) or upload the built zip (Plugins → Upload plugin). See Publishing & Distribution.
Local Development (CLI)
The developer toolchain is one npm package — selldoes — so you can build a plugin in plain Node without cloning the platform:
selldoes— types forplugin.json,PluginContextandPluginExports(definePlugin())selldoes/theme— the React theme runtime (hooks + components)npx selldoes create— scaffolder (plugin or theme, interactive)npx selldoes dev / build / pack / validate / publish— the CLI
Preview server
npm run dev builds the runtime bundle and serves a preview UI athttp://127.0.0.1:4590/preview:
- Dashboard UI — your
ui/entry in a sandboxed iframe, exactly like the host, calling/api/plugin-api/<slug>/** - Storefront — a demo store with your
storefrontWidgetand everystorefrontPagesentry - API console — send requests to declared routes and inspect responses
- Jobs / Hooks — run job ticks and fire hook handlers with JSON payloads
- Data / Email / Realtime — inspect the mock database, sent emails and published events
The mock context enforces the same permission checks, store scoping and allowed-table rules as production, and persist to.selldoes-dev/db.json. Production additionally executes your code in a QuickJS sandbox — avoid Node globals (process, Buffer,require, fs).
Commands
npx selldoes dev # preview server (dashboard UI, storefront, API console, jobs, hooks)
npx selldoes validate # manifest, entries and route checks
npx selldoes build # bundle into dist/<slug>/
npx selldoes pack # build + zip for manual upload
npx selldoes publish # upload + marketplace listingRuntime capabilities
The dev server exposes the same ctx as the platform — see the Sandbox API and Permission System sections. Storefront integrations are declared in the manifest:
{
"storefrontWidget": { "entry": "ui/widget.html", "width": 64, "height": 64 },
"storefrontPages": [
{ "path": "/kb", "title": "Help center", "entry": "ui/kb.html" }
],
"publicRoutes": [
{ "path": "/widget/start", "methods": ["POST"] }
]
}storefrontWidget renders bottom-right on every storefront page;storefrontPages adds public pages (like a help center) under the store URL; publicRoutes are callable by visitors without a dashboard session — both run in the sandbox with a store-scoped context.
Manifest (plugin.json)
Every plugin must have a plugin.json in its root directory. This file declares everything Selldoes needs to know about your plugin.
Complete Schema
{
// REQUIRED
"slug": "my-plugin", // Unique identifier (lowercase, hyphens only)
"name": "My Plugin", // Display name
"description": "What it does", // Shown in marketplace
"version": "1.0.0", // Semantic versioning
// OPTIONAL: Identity
"author": "Your Name", // Author name
"icon": "puzzle", // Lucide icon name
"homepage": "https://...", // Plugin homepage URL
"category": "ecommerce", // Marketplace category
"tags": ["billing", "checkout"],
// OPTIONAL: Security
"permissions": ["db:read"], // Required permissions (see below)
"allowedTables": ["products"], // Extra tables to access
// OPTIONAL: Entry
"entry": "./index.js", // Entry point (default: "./index.js")
// OPTIONAL: Extension Points (hooks)
// "component" paths point to built ESM .js in ui/ (host React:
// window.__SELDESK__.React); "handler" paths run server-side in the sandbox.
"hooks": {
"product:form:after": {
"component": "ui/ProductAddon.js"
},
"order:detail:after": {
"handler": "./handlers/OrderBadge.js"
},
"dashboard:page:bottom": {
"component": "ui/DashboardWidget.js"
},
"dashboard:products:columns": {
"handler": "./handlers/ProductColumns.js"
},
"storefront:sections": {
"types": ["my-hero", "my-testimonials"]
}
},
// OPTIONAL: Dashboard Pages
"dashboardPages": [
{ "label": "My Settings", "path": "/settings", "icon": "settings", "group": "My Plugin" }
],
// OPTIONAL: API Routes (served at /api/plugin-api/{slug}/path — authenticated,
// or /api/plugin-public/{slug}/path when listed in publicRoutes)
"apiRoutes": [
{ "path": "/data", "methods": ["GET", "POST"] }
],
// OPTIONAL: Storefront Sections
"sections": [
{ "type": "my-hero", "name": "Hero Banner", "description": "Custom hero section" }
],
// OPTIONAL: Settings form (rendered on the plugin's home page; each field
// may carry a learnMoreUrl shown as a link under the description)
"configSchema": [
{
"key": "apiKey",
"label": "API key",
"type": "secret",
"required": true,
"description": "Bearer token for the external API.",
"learnMoreUrl": "https://example.com/signup"
}
],
// OPTIONAL: Designed settings dialog — opens from the plugin manager's
// Settings button in a modal instead of the plugin home page. The SDK's
// Design > Settings page authors its "sections"; the kit compiler builds
// "entry" (ui/kit/settings.html). A hand-written entry wins.
"settingsDialog": {
"entry": "ui/kit/settings.html",
"sections": [{ "type": "settings" }, { "type": "links", "settings": { "items": [{ "label": "Docs", "href": "https://example.com/docs" }] } }]
},
// OPTIONAL: Dependencies
"dependencies": {
"lodash": "^4.17.21"
},
// OPTIONAL: Marketplace
"screenshots": [],
"minVersion": "1.0.0"
}Project Structure
plugins/
my-plugin/
plugin.json # Manifest (required)
index.js # Entry point (required)
package.json # Dependencies (optional)
node_modules/ # Auto-installed from package.json
components/ # React components for hooks
ProductAddon.tsx
DashboardWidget.tsx
handlers/ # Server-side hook handlers
OrderBadge.js
NotificationHandler.js
sections/ # Storefront section definitions
hero.tsx
assets/ # Static assets
icon.png
screenshot.pngPermission System
Plugins request permissions in their manifest. The host checks permissions before every operation. Plugins can never bypass permission checks.
| Permission | What It Grants |
|---|---|
db:read | Read from allowed system tables + own plugin_{slug}_* tables |
db:write | Write to allowed system tables + own tables |
db:schema | Create/modify plugin-owned tables (prefix: plugin_{slug}_) |
api:external | Make outbound HTTP requests (10s timeout) |
ai:use | Use the host AI/LLM providers (DeepInfra, OpenAI, OpenRouter) |
email:send | Send emails via host SMTP |
files:read / files:write | S3 file access |
webhooks:register | Register webhook endpoints |
sections:register | Register custom storefront section types |
dashboard:pages | Add dashboard pages |
System Tables You Can Access
With db:read/db:write, your plugin can access:
stores— Store configurationstore_pages— Store page sectionsstore_settings— Key-value settingsstore_categories— Product categoriescustomers— Customer recordsproducts— Product catalogorders— Order recordsorder_items— Order line items
Additionally, you can always access your own tables: plugin_my_plugin_*
Entry Point (index.js)
Your entry point exports lifecycle hooks and optional API route handlers.
// plugins/my-plugin/index.js
/**
* Called when the plugin is loaded.
* @param {PluginContext} ctx - Sandboxed context
*/
export function init(ctx) {
console.log("Plugin initialized");
console.log("Store ID:", ctx.storeId);
console.log("Permissions:", ctx.permissions);
console.log("Config:", ctx.config);
}
/**
* Called when the plugin is unloaded or disabled.
*/
export function destroy() {
console.log("Plugin destroyed");
}
/**
* Optional: API route handlers.
* Accessible at /api/plugins/{slug}/{routePath}
*/
export const apiRoutes = {
greeting: {
GET: async (ctx, req) => {
return { message: "Hello from my plugin!" };
},
},
"save-data": {
POST: async (ctx, req) => {
const { name, value } = req.body;
await ctx.db.insert("plugin_my_plugin_settings", {
store_id: ctx.storeId,
name,
value: JSON.stringify(value),
});
return { success: true };
},
},
};
/**
* Optional: Delivery provider for order detail pages.
* Return items to display in the "Digital Deliveries" section.
*/
export async function deliveryProvider(storeId, order) {
return null; // or return { id: "my-delivery", title: "Download", items: [...] }
}
export default { init, destroy, apiRoutes, deliveryProvider };Plugin Lifecycle
- Discovery — Plugin code found in
plugins/directory or uploaded - Validation —
plugin.jsonparsed and validated against schema - Dependency Install —
npm install --productionruns in plugin directory - Bundling — esbuild bundles source + deps into single JS file (Tier 1/2 only)
- Loading —
require()(Tier 0) orisolate.compileScript()(Tier 1/2) - Initialization —
init(ctx)called with sandboxed context - Execution — Hooks fire at registered extension points
- Teardown —
destroy()called on uninstall/disable
Execution Backends
| Backend | Environment | Isolation |
|---|---|---|
process | Local dev (Windows) | Permission-restricted Node subprocess (default) |
quickjs | Any | QuickJS WASM VM in a child process (no Node authority) |
lambda | AWS Lambda (plugin runtime) | Shared sandboxed Lambda + signed HTTP bridge |
Selected with PLUGIN_EXECUTOR=process|quickjs|lambda (default process).
Sandbox API (Plugin Context)
Every plugin receives a PluginContext object. All operations are permission-checked and rate-limited. Plugins cannot access the filesystem, network, or environment directly.
// The ctx object passed to init(), apiRoutes, and hook handlers
interface PluginContext {
storeId: number; // Current store ID
permissions: string[]; // Granted permissions
tablePrefix: string; // "plugin_my_plugin_" — your table prefix
config: Record<string, unknown>; // From store_plugins.config
// Database (permission-checked)
db: {
select(table, conditions?, opts?)
insert(table, data)
update(table, conditions, data)
delete(table, conditions)
}
// HTTP (permission-gated: requires api:external)
http: {
get(url, headers?)
post(url, body, headers?)
}
// AI (permission-gated: requires ai:use)
ai: {
complete({ prompt, systemPrompt?, model?, maxTokens?, temperature? })
image({ prompt?, promptId?, referenceImage?, size?, aspectRatio? })
listPrompts({ type? }) // the store's saved prompt library (Dashboard → AI Prompts)
getPrompt(id)
}
}Database Examples
// Read from your own plugin tables (always allowed)
const settings = await ctx.db.select("plugin_my_plugin_settings", {
store_id: ctx.storeId
});
// Read from system tables (requires db:read + table in allowedTables)
const products = await ctx.db.select("products", { store_id: ctx.storeId });
// Insert
await ctx.db.insert("plugin_my_plugin_configs", {
store_id: ctx.storeId,
key: "theme",
value: "dark"
});
// Update
await ctx.db.update("plugin_my_plugin_configs",
{ store_id: ctx.storeId, key: "theme" },
{ value: "light" }
);
// Delete
await ctx.db.delete("plugin_my_plugin_configs", {
store_id: ctx.storeId,
key: "theme"
});HTTP Examples
// GET request
const { status, data } = await ctx.http.get(
"https://api.example.com/data",
{ Authorization: "Bearer token" }
);
// POST request
const { status, data } = await ctx.http.post(
"https://hooks.slack.com/xxx",
{ text: "Hello from my plugin!" }
);AI Examples
// Use AI through the host's configured providers
const { text, tokensUsed } = await ctx.ai.complete({
prompt: "Generate a product description for: " + product.name,
systemPrompt: "You are a helpful e-commerce assistant.",
model: "gpt-4o-mini",
maxTokens: 500,
temperature: 0.7
});
// Generate a product image from a saved prompt + reference image
const { url } = await ctx.ai.image({
promptId: 15, // Dashboard → AI Prompts (or pass raw "prompt")
referenceImage: "https://…", // source to redraw / upscale
size: "1600x1600",
aspectRatio: "1:1"
});
// List the store's saved AI prompts so your UI can offer a picker
// instead of asking the merchant for a hard-coded prompt id
const prompts = await ctx.ai.listPrompts({ type: "image" });
// → [{ id, name, description, type, field, template, provider, model, isDefault, aspectRatio }]
const prompt = await ctx.ai.getPrompt(15); // null when missing / not visibleExtension Points (Hooks)
Hooks let plugins inject functionality into existing Selldoes pages. Declare hooks in your plugin.json manifest. There are two types of hooks:
- Server hooks (
handler) — Run on the server, return data - Client hooks (
component) — Render UI in page slots (iframe for external plugins, built ESM for dashboard columns/slots)
Exporting server hook handlers
The manifest's handler path is a name hint — the platform resolves the actual function from your entry bundle's exports. Two conventions are accepted everywhere (platform runtime, npx selldoes dev preview):
// Recommended — an exported hooks map keyed by hook point (unambiguous
// when one bundle serves several hooks):
export const hooks = {
"order:detail:after": async (payload, ctx) => ({ badge: "Digital Delivery" }),
"dashboard:products:columns": async () => ({ columns: [/* … */] }),
}
// Also works — named export matching the handler file's basename
// (./handlers/OrderBadge.js → export function OrderBadge):
export async function OrderBadge(payload, ctx) { /* … */ }
// Also works — default export (single-handler bundles only):
export default async function (payload, ctx) { /* … */ }Server Hook Example
// plugin.json
{
"hooks": {
"order:detail:after": {
"handler": "./handlers/OrderBadge.js"
}
}
}
// index.js — the hooks map is the canonical, unambiguous form
export const hooks = {
"order:detail:after": async (payload, ctx) => {
const { order } = payload;
const licenses = await ctx.db.select("licenses", { order_id: order.id });
return { badge: licenses.length > 0 ? "Digital Delivery" : null };
},
};Client Hook Example
External plugin client hooks render in a sandboxed same-origin iframemounted at the hook slot, so the component path points to a browser-ready entry file (HTML or built ESM) inside your plugin — not TSX. The iframe URL carries the payload as query parameters (storeId, andproductId for object payloads), and the iframe can resize itself by posting { type: "selldesk:resize", height } to the parent window.
// plugin.json
{
"hooks": {
"product:form:after": {
"component": "ui/ProductAddon.html"
}
}
}
// ui/ProductAddon.html — plain HTML + JS, talks to your apiRoutes
<div id="root"></div>
<script type="module">
const { createElement: h } = window.parent.__SELDESK__?.React ?? {};
// …or render with plain DOM/fetch — the iframe is your sandbox.
const storeId = new URLSearchParams(location.search).get("storeId");
const res = await fetch("/api/plugin-api/my-plugin/data?storeId=" + storeId);
document.getElementById("root").textContent = JSON.stringify(await res.json());
</script>Client Hook with Section Tab
Client hooks can optionally register a section tab in the page's scroll-spy navigation. This is useful for hooks that add a significant section of content (e.g., a sellers panel in the product form). Declare a section object with an id and label:
// plugin.json
{
"hooks": {
"product:form:after": {
"component": "ui/ProductAddon.html",
"section": { "id": "my-addon", "label": "My Addon" }
}
}
}PluginSlot wraps your iframe in a <section id={id}>element and registers it with the scroll spy. The tab appears in the page's sticky navigation bar and scrolls to the section when clicked. If the plugin is not activated, neither the tab nor the section renders.
Built-in Plugin Hooks
Built-in plugins are statically compiled into the host and don't use plugin.json manifests. There are currently none: every plugin (helpdesk, marketplace, product-keys, fraud) is external and ships its own iframe-hosted UI.
External Plugin Hook UI
External plugins declare a UI entry in their manifest and it renders in a sandboxed, same-origin iframe at the hook point:
// plugin.json
"hooks": {
"product:form:after": {
"component": "ui/product-section.html",
"section": { "id": "sellers", "label": "Sellers" }
}
}The frame receives the hook payload as query parameters (objects contribute <key>Id) and can resize itself with parent.postMessage({ type: "selldesk:resize", height }). All data access goes through the plugin's API routes, where store scoping and permissions are enforced.
Declaring Hooks in a Built-in Plugin
Add a hooks field to your plugin definition:
// src/lib/plugins/my-plugin/index.ts
import type { PluginDefinition } from "../types"
export const myPlugin: PluginDefinition = {
slug: "my-plugin",
name: "My Plugin",
description: "...",
icon: "Settings",
hooks: {
"product:form:after": {
component: "./components/MySection",
section: { id: "my-section", label: "My Section" },
},
"dashboard:page:bottom": {
component: "./components/DashboardWidget",
},
},
}Registering the Component
Built-in plugin components are resolved from a static registry, not dynamically imported. Add your component tosrc/lib/plugins/builtin-components.ts:
// src/lib/plugins/builtin-components.ts
import type { ComponentType } from "react"
import { MySection } from "./my-plugin/components/MySection"
export const builtinPluginComponents: Record<
string,
Record<string, ComponentType<Record<string, unknown>>>
> = {
"my-plugin": {
"./components/MySection": MySection,
},
}How It Works
- The host checks which built-in plugins are active for the store (via
store_pluginstable) - For each active plugin with a matching hook, the component path is looked up in the registry
- The
PluginSlotrenders the resolved component - If the plugin is not activated, nothing renders
PluginDefinition Hooks Interface
interface PluginHookHandler {
/** Path to the component relative to the plugin directory */
component: string
/** Optional: Register a scroll-spy section tab */
section?: {
id: string // Must match the <section> element's id
label: string // Displayed in the nav bar
}
}
interface PluginDefinition {
// ... existing fields
hooks?: Partial<Record<HookPoint, PluginHookHandler>>
}Dynamic Section Tabs
Pages with scroll-spy navigation (like the product form) can dynamically add/remove nav tabs based on which plugins are active. This is handled by the PluginSlot component's onSections andregisterSection props.
How It Works
- The page starts with a base set of sections (e.g., Information, Pricing, Media)
PluginSlotfetches active plugins and discovers which ones register section tabsPluginSlotcallsonSections()with the discovered tab metadata- The page merges plugin sections with base sections and re-renders the nav and scroll spy
PluginSlotwraps each section-registered component in a<section>element with the correctreffor scroll tracking
Host Page Setup
// page.tsx
import { useState, useMemo } from "react"
import { PRODUCT_SECTIONS, useSectionScrollSpy } from "./use-section-scroll-spy"
import { PluginSlot, type PluginSectionTab } from "@/lib/plugins/sandbox/plugin-slot"
import { ProductForm, type ProductFormPluginSections } from "@/components/product-form"
export default function ProductPage() {
const [scrollEl, setScrollEl] = useState<HTMLDivElement | null>(null)
const [pluginSections, setPluginSections] = useState<ProductFormPluginSections>([])
const allSections = useMemo(
() => [...PRODUCT_SECTIONS, ...pluginSections],
[pluginSections]
)
const spy = useSectionScrollSpy(allSections, scrollEl)
return (
<>
<NavTabs sections={allSections} activeId={spy.activeId} />
<div ref={setScrollEl}>
<PluginSlot
hook="product:form:after"
payload={{ product, storeId }}
registerSection={spy.registerSection}
onSections={setPluginSections}
/>
</div>
</>
)
}Section Tab Lifecycle
- Plugin active + has
section— Tab appears, component renders in a<section>wrapper with scroll tracking - Plugin active + no
section— Component renders without a tab or<section>wrapper - Plugin inactive — Nothing renders, no tab appears
Supported Hook Points with Section Tabs
Any client hook (component) can declare a section. The most common use case is product:form:after for adding sections to the product form's scroll-spy navigation.
| Hook Point | Nav Context | Example |
|---|---|---|
product:form:after | Product form scroll spy | Sellers, Custom Fields |
product:form:sidebar | Product form sidebar | — |
product:form:before | Product form top | — |
Dashboard Extension Points
Plugins can inject content into any dashboard page. Use theDashboardPluginSlot component or declare hooks in your manifest.
Available Dashboard Hook Points
| Hook Point | Where It Renders |
|---|---|
dashboard:sidebar | Add items to the sidebar navigation |
dashboard:header | Inject into the dashboard header bar |
dashboard:footer | Inject into the dashboard footer |
dashboard:page:top | Top of every core page (Products, Orders, Customers, Settings, Analytics, Overview) |
dashboard:page:bottom | Bottom of every core page |
dashboard:page:sidebar | Sidebar area (reserved — not yet mounted) |
dashboard:orders:top | Top of the Orders page |
dashboard:orders:bottom | Bottom of the Orders page |
dashboard:orders:columns | Custom columns in the Orders table (handler only) |
dashboard:products:top | Top of the Products page |
dashboard:products:bottom | Bottom of the Products page |
dashboard:products:columns | Custom columns in the Products table (handler only) |
dashboard:customers:top | Top of the Customers page |
dashboard:customers:bottom | Bottom of the Customers page |
dashboard:customers:columns | Custom columns in the Customers table (handler only) |
dashboard:settings:top | Top of the Settings page |
dashboard:settings:bottom | Bottom of the Settings page |
dashboard:analytics:top | Top of the Analytics page |
dashboard:analytics:bottom | Bottom of the Analytics page |
dashboard:overview:top | Top of the Overview page |
dashboard:overview:bottom | Bottom of the Overview page |
settings:tabs:after | After the settings tab list (payload includes activeTab) |
Using DashboardPluginSlot in Host Code
Any dashboard page can render plugin slots by importing the component:
import { DashboardPluginSlot } from "@/lib/plugins/sandbox/dashboard-slot"
// At the top of any dashboard page:
<DashboardPluginSlot hook="dashboard:orders:top" storeId={storeId} pageData={{ orders }} />
// At the bottom:
<DashboardPluginSlot hook="dashboard:orders:bottom" storeId={storeId} />Plugin Manifest Declaration
component hooks point to built ESM JavaScript files in your plugin's ui/ directory (browsers cannot execute TSX — runselldoes build to compile). They are loaded with the host React fromwindow.__SELDESK__.React. handler hooks run server-side in the sandbox.
{
"hooks": {
"dashboard:page:bottom": {
"component": "ui/DashboardBanner.js"
},
"dashboard:orders:top": {
"handler": "./handlers/OrderSummary.js"
},
"dashboard:sidebar": {
"component": "ui/SidebarWidget.js"
}
}
}Injecting Custom Columns into Table Pages (Products, Orders, Customers)
Use the dashboard:{products|orders|customers}:columns hook to add custom columns to a DataTable. These are server-side handler hooks — your handler returns column definitions that are merged into the table.
Manifest Declaration
{
"hooks": {
"dashboard:products:columns": {
"handler": "./handlers/ProductColumns.js"
}
}
}Handler Signature
Your handler receives (payload, ctx) and must return:
// handlers/ProductColumns.js
module.exports = async function(payload, ctx) {
// payload = { storeId }
// ctx = PluginContext (db, http, ai access)
return {
columns: [
{
key: "my-plugin-data", // Unique column key
header: "My Data", // Column header label
group: "Plugin data", // Group label in the column picker
cellComponent: "ui/MyCell.js", // Cell renderer component (built ESM .js)
hideBelow: "lg", // Optional: responsive hide breakpoint
sortable: false, // Optional: enable sorting
sortKey: "my_data", // Optional: server-side sort key
compact: false, // Optional: tight padding
className: "text-right", // Optional: extra CSS classes
}
]
}
}Cell Renderer Component
The cellComponent path points to a React component that renders the cell content. It receives row (the product) and storeId as props. Cell components are served from your plugin's ui/ assets asbuilt ESM JavaScript (browsers cannot execute TSX — runselldoes build to compile). React is provided by the host aswindow.__SELDESK__.React; never bundle your own copy.
// ui/MyCell.js (built ESM — JSX compiled away)
const { createElement: h, useState } = window.__SELDESK__.React
export default function MyCell({ row, storeId }) {
// row = ProductRow (has id, name, sku, price, etc.)
// storeId = current store ID
return h("span", { className: "text-xs text-muted-foreground" },
"Custom data for " + row.name)
}Toasting from Plugin UI
The host also exposes its toast function to plugin components aswindow.__SELDESK__.toast — the same shadcn toaster the dashboard uses. Use it for user-visible errors and confirmations from cell components, hook components, and any other plugin UI running in the dashboard.
// ui/MyCell.js
const { toast } = window.__SELDESK__
fetch("/api/plugin-api/my-plugin/run?storeId=" + storeId, { method: "POST" })
.then((r) => r.json().then((body) => ({ ok: r.ok, body })))
.then(({ ok, body }) => {
if (!ok) {
toast({
title: "My plugin failed",
description: body && body.error, // show the server's message
variant: "destructive",
})
return
}
toast({ title: "Done" })
})toast({ title, description?, variant? }) acceptsvariant: "destructive" for error styling and returns{ id, dismiss }.
// manifest.json
{
"hooks": {
"dashboard:products:columns": {
"handler": "./handlers/columns.js"
}
}
}
// handlers/columns.js returns:
{
columns: [{
key: "my-data",
header: "My Data",
group: "Metrics",
cellComponent: "ui/MyCell.js", // built .js, relative to the plugin root
sortable: true,
sortKey: "my_data",
sortEndpoint: "/sort", // apiRoutes entry returning sort values
}]
}Sorting a Plugin Column
Plugin data lives in plugin-owned tables, so the server's SQL sort can't reach it. Declare sortable: true, a sortKey and asortEndpoint (an apiRoutes entry). The dashboard callsGET /api/plugin-api/{slug}{sortEndpoint}?storeId=&ids=1,2,3 for the visible rows and expects { values: { [rowId]: number | string } }; clicking the column header then sorts the visible page client-side by those values (nullish values sort last).
Using the usePluginColumns Hook (Client-Side)
If you want to consume plugin columns in your own dashboard page:
import { usePluginColumns } from "@/hooks/use-plugin-columns"
function MyPage({ storeId }) {
const pluginColumns = usePluginColumns(storeId, "dashboard:products:columns")
// pluginColumns is DataTableColumn[] — merge with your own columns
const allColumns = [...myColumns, ...pluginColumns]
}Column Grouping in the Picker
The group property controls how columns appear in the column picker dropdown. Each group renders under a labeled separator. Built-in columns use groups like "Metrics", "Product details", "Store health", and "Channels".
Plugin columns default to a "Plugins" group if nogroup is specified. You can set any custom group name — if it matches an existing group, your column will appear alongside those columns.
return {
columns: [
{
key: "my-data",
header: "My Data",
group: "Metrics", // Appears alongside Sales, Views, etc.
cellComponent: "ui/MyCell.js", // built ESM .js in your plugin's ui/
}
]
}Column Visibility & Reordering
Plugin columns are visible by default in the column picker. Users can toggle them on/off via checkboxes. The column picker also supportsdrag-and-drop reordering — users can reorder plugin columns just like built-in columns. Columns not yet present in the stored order are appended at the end of the table.
Reset Behavior
The "Reset to default" button in the column picker restores the host's default visibility and order. This may hide plugin columns (depending on the host's defaults). Users can re-enable them from the column picker.
Storefront Extension Points
| Hook Point | Where It Renders |
|---|---|
storefront:sections | Register custom section types (server types declaration) |
storefront:header | Below the storefront header (every page, all templates) |
storefront:footer | Above the storefront footer (every page, all templates) |
storefront:product-page:after | After the product page content (payload includes sku — minimal template) |
checkout:before | Above the checkout form (minimal template) |
checkout:after | Below the checkout form (minimal template) |
How Storefront Components Render
Storefront visitors have no dashboard session, so these hooks work differently from dashboard component hooks:
- Handlers are discovered through the session-free route
GET /api/plugin-public/hooks?hook=&storeId=— onlystorefront:*andcheckout:*hook points are served there. - Each component renders in a sandboxed iframe served from
/api/plugin-public/{slug}/ui/{storeId}/{entry}. The entry can be an HTML file (or any browser-ready asset) declared in your manifest's hookcomponentpath. - The rendered HTML is visitor-independent — the storefront edge cache stays valid. Any per-visitor state (cart, customer) must be fetched inside the iframe.
- Resize your iframe by posting
{ type: "selldesk:resize", height: number }to the parent window. - The iframe URL receives
storeSlug(andskuon product pages) as query parameters.
// manifest.json
{
"hooks": {
"storefront:header": { "component": "ui/HeaderBanner.html" },
"checkout:after": { "component": "ui/CheckoutUpsell.html" }
}
}Complete Hook Points Reference
Every available hook point in Selldoes, organized by area. All hooks listed here have a live firing point in the platform:
Product Hooks
| Hook | Payload | Type |
|---|---|---|
product:form:before | { product, storeId, isEdit } | Component |
product:form:after | { product, storeId, isEdit } | Component |
product:form:sidebar | { product, storeId, isEdit } | Component |
product:list:after | { products, storeId } | Component |
Category Hooks
| Hook | Payload | Type |
|---|---|---|
category:form:after | { category, storeId } | Component |
category:list:after | { categories, storeId } | Component |
Order Hooks
| Hook | Payload | Type |
|---|---|---|
order:detail:before | { order, storeId } | Component |
order:detail:after | { order, storeId } | Component |
order:list:after | { orders, storeId } | Component |
order:email:before | { order, items, storeId } | Handler — fires before the order receipt email is sent. Return { subject?, html?, text?, skip? } to modify or suppress it. |
Customer Hooks
| Hook | Payload | Type |
|---|---|---|
customer:profile:after | { customer, storeId } | Component |
customer:list:after | { customers, storeId } | Component |
Checkout Hooks
| Hook | Payload | Type |
|---|---|---|
checkout:before | { storeId, storeSlug } | Component (rendered in an iframe above the checkout form — minimal template) |
checkout:after | { storeId, storeSlug } | Component (rendered in an iframe below the checkout form — minimal template) |
Storefront Hooks
| Hook | Payload | Type |
|---|---|---|
storefront:sections | { sectionType } | Section Types |
storefront:header | { storeId, storeSlug } | Component (iframe below the header — all templates) |
storefront:footer | { storeId, storeSlug } | Component (iframe above the footer — all templates) |
storefront:product-page:after | { storeId, storeSlug, sku } | Component (iframe after the product page — minimal template) |
Storefront hooks render in sandboxed iframes served from/api/plugin-public/{slug}/ui/{storeId}/{entry} — no dashboard session is needed, per-visitor state must be fetched inside the iframe, and the storefront edge cache stays valid because the HTML is identical for every visitor. Your entry HTML can resize its iframe by posting{ type: "selldesk:resize", height } to the parent window.
Dashboard Hooks
Mounted on every core dashboard page. Server handler hooks run in the sandbox; component hooks are built ESM modules loaded with the host React (window.__SELDESK__.React) and can raise host toasts viawindow.__SELDESK__.toast({ title, description?, variant? }).
| Hook | Payload | Type |
|---|---|---|
dashboard:sidebar | { storeSlug } | Component |
dashboard:header | { storeSlug } | Component |
dashboard:footer | { storeSlug } | Component |
dashboard:page:top | { storeId, pageName } | Component (fires at the top of every core page) |
dashboard:page:bottom | { storeId, pageName } | Component (fires at the bottom of every core page) |
dashboard:page:sidebar | { storeId, pageName, ...pageData } | Component / Handler (reserved — not yet mounted) |
dashboard:orders:top | { orders, storeId } | Component |
dashboard:orders:bottom | { orders, storeId } | Component |
dashboard:orders:columns | { storeId } | Handler (returns column defs — sortable via sortEndpoint) |
dashboard:products:top | { products, storeId } | Component |
dashboard:products:bottom | { products, storeId } | Component |
dashboard:products:columns | { storeId } | Handler (returns column defs — sortable via sortEndpoint) |
dashboard:customers:top | { customers, storeId } | Component |
dashboard:customers:bottom | { customers, storeId } | Component |
dashboard:customers:columns | { storeId } | Handler (returns column defs — sortable via sortEndpoint) |
dashboard:settings:top | { storeId } | Component |
dashboard:settings:bottom | { storeId } | Component |
dashboard:analytics:top | { metrics, storeId } | Component |
dashboard:analytics:bottom | { metrics, storeId } | Component |
dashboard:overview:top | { overview, storeId } | Component |
dashboard:overview:bottom | { overview, storeId } | Component |
settings:tabs:after | { storeId, activeTab } | Component (after the settings tab bar) |
System Hooks
| Hook | Payload | Type |
|---|---|---|
api:middleware | { req: { method, path, query }, route } | Handler — fires on every plugin API request (/api/plugin-api/… and /api/plugin-public/…) before the target plugin's route runs. Return { status, body? } to block or override; first response wins. Not fired on core platform routes. |
webhook:received | { event, payload, storeId } | Handler — fires (fire-and-forget) on all seven payment webhooks (Stripe, PayPal, Mollie, Square, Klarna, MercadoPago, Afterpay) after signature verification. Use it for side effects: fraud scoring, syncing, notifications. |
Custom API Routes
Declare API routes in your manifest. They are served under two dispatchers — both run your handler inside the sandbox with a store-scoped ctx:
// Authenticated (dashboard session, store ownership required):
GET/POST/PUT/PATCH/DELETE /api/plugin-api/{slug}/{routePath}?storeId=
// Public (storefront visitors — only routes listed in "publicRoutes"):
GET/POST/PUT/PATCH/DELETE /api/plugin-public/{slug}/{routePath}?storeSlug=|storeId=// plugin.json
{
"apiRoutes": [
{ "path": "/settings", "methods": ["GET", "POST"] },
{ "path": "/metrics", "methods": ["GET"] },
{ "path": "/import", "methods": ["POST"], "tickBudgetMs": 30000 }
],
"publicRoutes": [
{ "path": "/lookup", "methods": ["GET"] }
]
}
// index.js — handlers receive (ctx, request); request.body is already parsed
export const apiRoutes = {
"/settings": {
GET: async (ctx, request) => {
// Short table names resolve to plugin_<slug>_<name>; store scoping is
// forced by the sandbox (you can NOT see or address other stores).
const rows = await ctx.db.select("settings", { key: { like: request.query.prefix || "" } });
return { settings: rows };
},
POST: async (ctx, request) => {
const { key, value } = request.body; // parsed JSON
const existing = await ctx.db.select("settings", { key }, { limit: 1 });
if (existing.length > 0) {
await ctx.db.update("settings", { key }, { value: JSON.stringify(value) });
} else {
await ctx.db.insert("settings", { key, value: JSON.stringify(value) });
}
return { success: true };
},
},
"/metrics": {
GET: async (ctx, request) => {
const ids = String(request.query.ids || "").split(",").map(Number).filter(Number.isFinite);
const rows = await ctx.db.select("search_metrics", { product_id: { in: ids } }, { limit: 500 });
return { values: Object.fromEntries(rows.map((r) => [r.product_id, r.score])) };
},
},
};Handler contract
ctx— the sandboxed plugin context (db,http,products,files,config, …). Permissions are enforced server-side.request—{ method, path, query, body }; the body is already parsed JSON (or a raw string).- Return a JSON-serializable value, or
{ status, body }to control the HTTP status code. api:middlewareplugins see every request to these dispatchers before the target plugin runs and may short-circuit it with{ status, body }.tickBudgetMs— per-request handler time budget in milliseconds (default 10 000, clamped to 30 000). Raise it for routes that do slow host work, e.g. re-hosting images or processing a batch of rows.
Conventions
GET /test-connection— declare this route and the plugin home page's settings form shows a Test connection button (e.g. an API-key balance check). Return{ ok: boolean, message?: string }.
Custom Dashboard Pages
Add pages to the dashboard sidebar via your manifest:
// plugin.json
{
"dashboardPages": [
{
"label": "My Settings",
"path": "/my-settings",
"icon": "settings",
"group": "My Plugin"
}
]
}Pages render at /dashboard/{storeSlug}/plugins/{slug}/my-settings. The component receives { storeSlug, pluginSlug } as props.
Dialogs & toasts in plugin UI (sandboxed frames)
Every plugin UI — dashboard pages, hook slots, storefront widgets — renders inside a sandboxed iframe without allow-modals. window.confirm(), window.alert(), window.prompt() and print() are therefore silently blocked (they resolve false / do nothing). A confirm button wired to window.confirm looks broken: the click does nothing at all.
<dialog>.showModal() is not gated by the sandbox. The sanctioned replacement is the shared kit helper, which the SDK ships as ui/kit/dialog.js next to the kit runtime:
<script src="kit/dialog.js"><\/script>
<script>
// Plain confirm — resolves null on cancel, {} on confirm.
const ok = await SelldoesDialog.confirm({
title: "Discard these drafts?",
body: "They move to the Discarded list — restore anytime.",
confirmLabel: "Discard",
danger: true, // red button for destructive actions
});
if (!ok) return;
// Confirm with a field — resolved values come back as an object.
const values = await SelldoesDialog.confirm({
title: "Add to store?",
confirmLabel: "Add to store",
select: {
name: "status",
label: "Status",
value: "draft",
options: [
{ value: "draft", label: "Draft" },
{ value: "active", label: "Active" },
],
},
});
if (!values) return; // cancelled
await addProducts(values.status);
SelldoesDialog.toast("Added 12 products"); // default
SelldoesDialog.toast("Import failed", { variant: "error" }); // red
</script>The helper injects its own <dialog> and styles on first use — no CSS file, no dependencies — and works in kit pages, embedded component iframes and standalone UI pages alike. Manual alternative: your own <form method="dialog"> +showModal(). Never use window.confirm in plugin UI.
Storefront Sections
Register custom section types that merchants can add to their store pages:
// plugin.json
{
"sections": [
{ "type": "my-testimonials", "name": "Testimonials Slider", "description": "Customer testimonials carousel" }
],
"hooks": {
"storefront:sections": {
"types": ["my-testimonials"]
}
}
}Section components must be registered in the template section registry or loaded dynamically by the host when rendering storefront pages.
Order Delivery
Plugins can add "digital deliveries" to the customer's order detail page. For example, license keys, download links, or access codes.
// index.js
export async function deliveryProvider(storeId, order) {
const ctx = { storeId, db: ... }; // sandboxed context
const keys = await ctx.db.select("plugin_my_plugin_keys", {
order_id: order.id
});
if (keys.length === 0) return null;
return {
id: "my-plugin-delivery",
title: "License Keys",
items: keys.map((k) => ({
label: "License Key",
value: k.key,
})),
};
}Publishing & Distribution
Delivery methods
- Marketplace — build a zip (or use the CLI), publish it, and stores install/update per store
- ZIP Upload — Dashboard → Plugins → Upload plugin for a single store
- Local dev install — with
PLUGIN_DEV_DIRset, install straight from the source folder (Developer tab)
Publishing with the CLI
# Merchant flow — uploads to your store, then submits for review:
SELLDOES_SESSION_COOKIE="session=…" npm run publish -- \
--app-url https://selldoes.com --store 123
# CI / first-party flow (service token):
npm run publish -- --app-url https://selldoes.com --token $SELLDOES_PUBLISH_TOKENEach publish writes an immutable release to the plugins bucket (releases/<slug>/<version>/bundle.js + manifest.json + ui/**, with a stable.json pointer) and upserts the marketplace listing as pending review. Admins approve or reject listings in the dashboard; a rejected publish never becomes installable.
Versions & updates
- Bump
versioninplugin.jsonfor every release — releases are immutable per version - Installed stores see Update available → Update now when an approved version is newer
- Approving a new version updates installed copies in place and keeps the previous bundle for admin rollback
- Paid plugins carry
price,currency,billing_periodandtrial_days; licenses are issued on purchase
Developer dashboard
Your listings, sales and payouts live under the plugin manager's Developer tab (installs, revenue share, payout batches). Merchants upload their own plugins from the same page and can clean history, data and orphaned tables per plugin.
Security Model
- Plugins run in permission-checked sandboxes
- No direct filesystem access — use S3 via
ctx.http - No direct network access — all HTTP goes through permission-gated proxy
- No access to
process.env,require(), orchild_process - DB queries are table-scoped and permission-checked
- HTTP requests have 10-second timeouts
- AI calls rate-limited per store
- Isolates have 64-128MB memory limits and 5-second script timeouts
- Plugins never share memory, state, or references with each other
Best Practices
- Keep
plugin.jsonminimal — only request permissions you need - Use your own prefixed tables (
plugin_{slug}_*) for plugin data - Handle errors gracefully — never let exceptions crash the host
- Use
async/await— all sandbox APIs are async - Cache expensive operations using your own DB tables
- Keep hook handlers fast — the host has timeouts
- Test in Tier 0 (local dev) before deploying to production
- Include a
README.mdwith usage instructions - Version your plugins with semantic versioning
- Never store secrets in plugin code — use
ctx.config