Skip to content

Frontend plugin development

This guide explains how to implement and register a new frontend plugin in the application. It is the frontend counterpart to the backend plugin development guide.

The plugin system is designed to be:

  • Modular
  • Lazy-loaded
  • Route-based

All paths in this guide are relative to the frontend/ directory.

Plugin Directory

All plugins live under:

plugins/

1. Defining a new plugin

Create a new folder using the plugin’s kebab-case name:

plugins/<plugin-name>/

Example:

plugins/example-plugin/

2. Create the Plugin UI Component

Inside the new plugin directory, create a React component that represents the plugin’s UI.

File

plugins/<plugin-name>/<PluginName>.tsx

Example:

"use client";

export default function ExamplePlugin() {
  return (
    <div className="p-6">
      <h1 className="text-xl font-semibold">Example Plugin</h1>
      {/* Plugin UI goes here */}
    </div>
  );
}

Notes

  • Must be a client component
  • Reads its own state and config with usePlugin from @/lib/plugins/context
  • This component is lazy-loaded
  • Takes shared UI components from @/lib/components/ui/<Name>, for example import { Button } from "@/lib/components/ui/Button";. Core UI lives in @/core, and make lint.frontend rejects any plugin import from it.
  • Reads the signed-in user and their token with useAuth from @/lib/auth/hooks.

3. Define the Plugin (index.ts)

Each plugin must export a PluginDefinition from its own index.ts.

Example Plugin Structure

plugins/
 └─ chat/
    ├─ ChatInterface.tsx
    └─ index.ts

File

plugins/<plugin-name>/index.ts

Example

import { PluginDefinition } from "@/lib/plugins/types";

export const examplePlugin: PluginDefinition = {
  name: "example-plugin",
  loadComponent: () => import("./ExamplePlugin"),
};

Note

A PluginDefinition holds only what the frontend alone can own:

  • name – unique plugin identifier (must match folder and route, and the backend plugin's declared name (see "Plugin Names" in the backend plugin development guide), so the UI resolves to the right backend plugin)
  • loadComponent – lazy-loaded UI component

The display name, description, icon, and sidebar entry are declared by the backend plugin through the frontend metadata hooks (DISPLAY_INFO, SIDEBAR_ENTRIES, FRONTEND_APPS, see the backend guide) and arrive on the user-plugins API response (display, sidebar, has_frontend on UserPluginState). The frontend renders what the backend declares; there is no frontend-side copy to keep in sync.

UserPluginState is the generated UserPluginResponse schema (Schema<"UserPluginResponse">), not a hand-written mirror of it, so the shape cannot drift from the API. One consequence for settings UIs: config values are typed unknown, because the backend stores config as JSONB and a field declared as a string can still arrive as an array or a number. Narrow at the read site.

Icons cross the wire as names, such as a lucide icon name or a brand name. Each plugin sets the component for every icon name its backend declares in the PLUGIN_ICONS slot (see Slots), from its icons.ts, which its index.ts imports. A name is looked up only among the icons of the plugin that declares it, so two plugins can both declare plus. Core keeps no icon map of its own, so an icon name its plugin did not set renders without an icon, and the resolver logs a console.warn outside production to surface the gap:

// plugins/example-plugin/icons.ts
import { Plus } from "lucide-react";
import { PLUGIN_ICONS } from "@/lib/icons/slots";

PLUGIN_ICONS.set("example-plugin", [{ name: "plus", component: Plus }]);

For more details, see the PluginDefinition type in frontend/core/plugins/types.ts.

4. Register the Plugin

Import the plugin's index.ts in app/plugins.ts, once, and register its PluginDefinition with the plugin registry. Importing the index.ts also runs any slot files it imports, such as settings.ts.

File

app/plugins.ts
import { registerPlugin } from "@/core/plugins/registry";
import { chatPlugin } from "@/plugins/chat"; // already existing
import { examplePlugin } from "@/plugins/example-plugin";

registerPlugin(chatPlugin); // already existing
registerPlugin(examplePlugin);

app/LocaleProvider.tsx and the dashboard layout import this module, so the plugins are registered on every page before their catalogs load.

⚠️ If a plugin is not imported and registered here, it will not load, not render, and not appear in the sidebar.

5. Regenerate the Plugin Route List

Each plugin is rendered under:

dashboard/<pluginName>

The static routes are generated from the backend declarations, not hand-maintained: generateStaticParams in app/dashboard/[pluginName]/page.tsx reads FRONTEND_PLUGIN_NAMES from lib/plugins/generated.ts, which lists every backend plugin that registered the FRONTEND_APPS hook. The same file lists, as PLUGIN_ICON_NAMES, the icon names each plugin declares in DISPLAY_INFO and SIDEBAR_ENTRIES. After declaring (or removing) a frontend app, or changing a declared icon, on the backend plugin, regenerate the file:

make frontend.build.plugins

Notes

  • Do not edit lib/plugins/generated.ts by hand. The dump script is its sole formatter, so the file is listed in .oxfmtrc.json ignorePatterns (as lib/api/generated.ts already is) — otherwise make format would rewrite it and break the byte-exact backend gate
  • To inspect the dump without touching the committed file, run the script with no arguments and it prints to stdout: uv run python scripts/dump_frontend_plugins.py. The make target passes -o frontend/lib/plugins/generated.ts, which writes atomically, so a failed dump leaves the committed file intact
  • Tests on both tiers fail when the committed file drifts from the backend declarations (tests/core/test_dump_frontend_plugins.py), when the frontend registry does not match it, or when a plugin sets no component for an icon name its backend declares (frontend/tests/app/plugins.test.ts)
  • PluginPageClient handles loading the correct plugin dynamically

Translations (Optional)

A plugin owns its message catalogs, mirroring the backend rule: the core messages/*.json files carry no plugin namespace, and a plugin with user-facing strings ships its own catalogs under its directory, scoped to a single top-level namespace equal to the plugin name.

1. Create the catalogs

plugins/example-plugin/messages/en.json
plugins/example-plugin/messages/es.json
plugins/example-plugin/messages/fr.json
{
  "example-plugin": {
    "greeting": "Hello from the example plugin"
  }
}

Every locale in lib/i18n/config.ts needs a file, all carrying the same keys.

2. Declare the loader on the plugin definition

import type { Locale } from "@/lib/i18n/config";

const messageCatalogs: Record<Locale, () => Promise<{ default: Record<string, unknown> }>> = {
  en: () => import("./messages/en.json"),
  es: () => import("./messages/es.json"),
  fr: () => import("./messages/fr.json"),
};

export const examplePlugin: PluginDefinition = {
  name: "example-plugin",
  loadComponent: () => import("./ExamplePlugin"),
  loadMessages: (locale) => messageCatalogs[locale]().then((catalog) => catalog.default),
};

loadMessages in lib/i18n/messages.ts merges every registered plugin's catalog into the core one at load time; a catalog that fails to load falls back to the plugin's English one.

3. Bind the types

Intersect the plugin's English catalog into the PluginMessages type in lib/i18n/messages.ts (type-only, so the plugin stays a self-contained runtime unit):

import type examplePluginMessages from "@/plugins/example-plugin/messages/en.json";

type PluginMessages = typeof chatMessages & typeof examplePluginMessages;

4. Use the namespace in components

const t = useTranslations("example-plugin");

<p>{t("greeting")}</p>

Plugin components use only their own namespace, never a core one. The catalog drift guard (make test.frontend.i18n) picks the new messages/ directory up automatically and checks it against the plugin's own sources, and frontend/tests/lib/i18n/messages.test.ts enforces the namespace containment. In component tests, pass the plugin catalog to the intl helper: renderWithIntl(<ExamplePlugin />, exampleEn). See the translations guide for the full picture.

Slots (Optional)

A plugin contributes to a core screen, or to another plugin, through a slot. A slot is the frontend counterpart of a backend hook, such as the one behind chat capabilities. It is called a slot so it is not confused with a React hook.

A slot keeps each plugin's items under the plugin's name. The code that reads the slot decides which plugins' items to show, for example only the plugins the user has turned on. Slots are defined in lib, the way backend hooks are defined in sparkth/lib/, so core and plugins import a slot from the same path and no plugin imports another.

1. Declare the slot in lib/<area>/slots.ts, with the type of item it holds:

import type { ComponentType } from "react";
import type { Slot } from "@/lib/slots";

export interface ExampleSection {
  title: string;
  loadComponent: () => Promise<{ default: ComponentType }>;
}

export const EXAMPLE_SECTIONS: Slot<ExampleSection> = new Map();

2. Set the plugin's items from a file of the plugin's own, named for what it contributes, all in one call under the plugin's name. Setting them again replaces them, so the dev server's hot reload, which runs the file again, adds nothing twice:

// plugins/example-plugin/sections.ts
import { EXAMPLE_SECTIONS } from "@/lib/example/slots";

EXAMPLE_SECTIONS.set("example-plugin", [
  { title: "Example", loadComponent: () => import("./ExampleSection") },
]);

A file runs only when something imports it, so the plugin's index.ts imports it, and the file runs when app/plugins.ts imports the plugin:

// plugins/example-plugin/index.ts
import "./sections";

3. Core reads the slot where its items are rendered. slotItems returns each item with the plugin that set it, ordered by plugin name, and leaves the filtering to the reader:

// core/components/example/ExampleSections.tsx
import { usePluginContext } from "@/core/plugins/context";
import { EXAMPLE_SECTIONS } from "@/lib/example/slots";
import { slotItems } from "@/lib/slots";

export function ExampleSections() {
  const { isPluginEnabled } = usePluginContext();
  const sections = slotItems(EXAMPLE_SECTIONS).filter(([plugin]) => isPluginEnabled(plugin));

  return (
    <ul>
      {sections.map(([plugin, section]) => (
        <li key={`${plugin}:${section.title}`}>{section.title}</li>
      ))}
    </ul>
  );
}

Settings

The Settings page lists the one row each plugin sets in the SETTINGS_ITEMS slot (@/lib/settings/slots), in plugin-name order, for every plugin the user-plugins API returns. The page passes each row its plugin's state and the actions to turn it on or off, and knows nothing about any plugin. A backend plugin whose frontend part sets no row does not appear on the page.

A row is a listItem component and, when the plugin has config, a loadConfigModal that loads the modal editing it. Core ships both: SettingsListItem (@/lib/components/settings/SettingsListItem) shows the plugin's name, description and on/off switch, and the config modal (@/lib/components/settings/ConfigModal) builds its form from the config class the backend plugin declares. Set the row from the plugin's settings.ts, under the backend plugin's name, and import that file from the plugin's index.ts (import "./settings";):

// plugins/example-plugin/settings.ts
import { SettingsListItem } from "@/lib/components/settings/SettingsListItem";
import { SETTINGS_ITEMS } from "@/lib/settings/slots";

SETTINGS_ITEMS.set("example-plugin", {
  listItem: SettingsListItem,
  loadConfigModal: () => import("@/lib/components/settings/ConfigModal"),
});

The row shows its configure button only while the plugin's backend config declares fields, so pass the shared modal even when the config has no fields yet: adding or removing fields on the backend then needs no frontend change. While the modal loads, the page passes the row configuring, and SettingsListItem shows a spinner on its configure button.

A backend plugin with no page still needs a frontend part for its row: its settings.ts and an index.ts that only imports it, with no PluginDefinition. app/plugins.ts imports that index.ts like any other plugin's, as import "@/plugins/canvas"; does for Canvas.

Field controls. A backend field picks its control with a widget hint (sparkth/core/plugins/widgets.py), and a field with no hint gets a control from its type. Core ships controls for text, URLs, passwords, numbers, text areas, checkboxes and LLM configs. When a field needs a control core doesn't ship, the plugin ships its own config modal that passes the control to core's modal under the hint's name. The control receives ConfigWidgetProps (@/lib/settings/slots):

import PluginConfigModal from "@/lib/components/settings/ConfigModal";
import type { ConfigModalProps } from "@/lib/settings/slots";
import ExamplePicker from "./components/ExamplePicker";

const CONTROLS = { "example-picker": ExamplePicker };

export default function ExampleConfigModal(props: ConfigModalProps) {
  return <PluginConfigModal {...props} controls={CONTROLS} />;
}

The plugin's row then loads that modal: loadConfigModal: () => import("./ExampleConfigModal"). Core's modal renders a field with the plugin's control for its hint, then with a built-in control, and falls back to a text input.

Plugin Loading Flow (Summary)

plugins/<plugin>
   ↓
PluginDefinition
   ↓
registerPlugin()
   ↓
Plugin registry
   ↓
Sidebar + routing
   ↓
Dynamic import
   ↓
Rendered plugin UI

Troubleshooting

Plugin not showing?

  • Ensure app/plugins.ts imports its index.ts and registers it

Plugin page blank?

  • Check loadComponent path
  • Ensure component is a default export
  • Ensure route param matches plugin name