Plugin Development Guide

Build plugins that extend any part of the Selldoes platform — dashboard pages, storefront sections, product forms, order workflows, and more.

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/preview

The 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-plugin

2. 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 for plugin.json, PluginContext and PluginExports (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 storefrontWidget and every storefrontPages entry
  • 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 listing

Runtime 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.png

Permission System

Plugins request permissions in their manifest. The host checks permissions before every operation. Plugins can never bypass permission checks.

PermissionWhat It Grants
db:readRead from allowed system tables + own plugin_{slug}_* tables
db:writeWrite to allowed system tables + own tables
db:schemaCreate/modify plugin-owned tables (prefix: plugin_{slug}_)
api:externalMake outbound HTTP requests (10s timeout)
ai:useUse the host AI/LLM providers (DeepInfra, OpenAI, OpenRouter)
email:sendSend emails via host SMTP
files:read / files:writeS3 file access
webhooks:registerRegister webhook endpoints
sections:registerRegister custom storefront section types
dashboard:pagesAdd dashboard pages

System Tables You Can Access

With db:read/db:write, your plugin can access:

  • stores — Store configuration
  • store_pages — Store page sections
  • store_settings — Key-value settings
  • store_categories — Product categories
  • customers — Customer records
  • products — Product catalog
  • orders — Order records
  • order_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

  1. Discovery — Plugin code found in plugins/ directory or uploaded
  2. Validation — plugin.json parsed and validated against schema
  3. Dependency Install — npm install --production runs in plugin directory
  4. Bundling — esbuild bundles source + deps into single JS file (Tier 1/2 only)
  5. Loading — require() (Tier 0) or isolate.compileScript() (Tier 1/2)
  6. Initialization — init(ctx) called with sandboxed context
  7. Execution — Hooks fire at registered extension points
  8. Teardown — destroy() called on uninstall/disable

Execution Backends

BackendEnvironmentIsolation
processLocal dev (Windows)Permission-restricted Node subprocess (default)
quickjsAnyQuickJS WASM VM in a child process (no Node authority)
lambdaAWS 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 visible

Extension 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

  1. The host checks which built-in plugins are active for the store (via store_plugins table)
  2. For each active plugin with a matching hook, the component path is looked up in the registry
  3. The PluginSlot renders the resolved component
  4. 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

  1. The page starts with a base set of sections (e.g., Information, Pricing, Media)
  2. PluginSlot fetches active plugins and discovers which ones register section tabs
  3. PluginSlot calls onSections() with the discovered tab metadata
  4. The page merges plugin sections with base sections and re-renders the nav and scroll spy
  5. PluginSlot wraps each section-registered component in a <section> element with the correct ref for 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 PointNav ContextExample
product:form:afterProduct form scroll spySellers, Custom Fields
product:form:sidebarProduct form sidebar—
product:form:beforeProduct 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 PointWhere It Renders
dashboard:sidebarAdd items to the sidebar navigation
dashboard:headerInject into the dashboard header bar
dashboard:footerInject into the dashboard footer
dashboard:page:topTop of every core page (Products, Orders, Customers, Settings, Analytics, Overview)
dashboard:page:bottomBottom of every core page
dashboard:page:sidebarSidebar area (reserved — not yet mounted)
dashboard:orders:topTop of the Orders page
dashboard:orders:bottomBottom of the Orders page
dashboard:orders:columnsCustom columns in the Orders table (handler only)
dashboard:products:topTop of the Products page
dashboard:products:bottomBottom of the Products page
dashboard:products:columnsCustom columns in the Products table (handler only)
dashboard:customers:topTop of the Customers page
dashboard:customers:bottomBottom of the Customers page
dashboard:customers:columnsCustom columns in the Customers table (handler only)
dashboard:settings:topTop of the Settings page
dashboard:settings:bottomBottom of the Settings page
dashboard:analytics:topTop of the Analytics page
dashboard:analytics:bottomBottom of the Analytics page
dashboard:overview:topTop of the Overview page
dashboard:overview:bottomBottom of the Overview page
settings:tabs:afterAfter 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 PointWhere It Renders
storefront:sectionsRegister custom section types (server types declaration)
storefront:headerBelow the storefront header (every page, all templates)
storefront:footerAbove the storefront footer (every page, all templates)
storefront:product-page:afterAfter the product page content (payload includes sku — minimal template)
checkout:beforeAbove the checkout form (minimal template)
checkout:afterBelow 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 routeGET /api/plugin-public/hooks?hook=&storeId= — onlystorefront:* and checkout:* 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 hook component path.
  • 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 (and sku on 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

HookPayloadType
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

HookPayloadType
category:form:after{ category, storeId }Component
category:list:after{ categories, storeId }Component

Order Hooks

HookPayloadType
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

HookPayloadType
customer:profile:after{ customer, storeId }Component
customer:list:after{ customers, storeId }Component

Checkout Hooks

HookPayloadType
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

HookPayloadType
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? }).

HookPayloadType
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

HookPayloadType
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:middleware plugins 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

  1. Marketplace — build a zip (or use the CLI), publish it, and stores install/update per store
  2. ZIP Upload — Dashboard → Plugins → Upload plugin for a single store
  3. Local dev install — with PLUGIN_DEV_DIR set, 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_TOKEN

Each 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 version in plugin.json for 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_period and trial_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(), or child_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.json minimal — 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.md with usage instructions
  • Version your plugins with semantic versioning
  • Never store secrets in plugin code — use ctx.config