Files
kandev/docs/plans/plugins/PLUGIN-API.md
T
2026-08-08 07:58:17 +02:00

37 KiB

Kandev Plugin API contract (native JS UI plugins — "option C")

This is the frozen interface every frontend + example task builds against. Do not diverge without updating this file.

Loading model

  1. Backend boot payload gains plugins: ActivePlugin[] where ActivePlugin = { id: string; name: string; bundleUrl: string; styleUrls?: string[] }. bundleUrl = /api/plugins/{id}/bundle — kandev serves this directly from the extracted package directory on local disk (~/.kandev/plugins/<id>/<version>/ui/..., per manifest ui.bundle). There is no reverse proxy and no live upstream request: the plugin subprocess does not need to be running to serve the UI bundle, since installation already extracted the file.
  2. On SPA boot, the plugin host (apps/web/lib/plugins/host.ts) iterates bootPayload.plugins, injects any styleUrls as <link>, and dynamically import(/* @vite-ignore */ bundleUrl) each bundle as a native ES module.
  3. Each bundle, when evaluated, calls the global:
    window.registerKandevPlugin(pluginId, {
      initialize(registry, host): void | Promise<void>,
      destroy?(): void,
    })
    
  4. After the module resolves, the host calls initialize(registry, host). A reload/update may unregister the previous generation before starting the next one; the host keeps that transition unresolved until the current generation's initialization finishes. Slow or failed reloads do not by themselves revoke open or saved task panels. On explicit plugin disable/uninstall the host calls destroy?.(), removes the plugin's registrations, and closes its panels.

Global entry point

window.registerKandevPlugin(id: string, plugin: KandevPlugin) — defined by the host before any bundle loads. Bundles are authored with React as an external; they must use host.React (NOT bundle their own React) to share the host instance.

host: PluginHostApi

interface PluginHostApi {
  pluginId: string;
  React: typeof import("react");            // host React instance (shared)
  jsx: typeof React.createElement;          // convenience alias (h)
  store: {                                   // kandev app store (zustand StoreApi)
    getState(): AppState;
    setState(partial): void;
    subscribe(listener): () => void;
  };
  api: {
    // fetch scoped to this plugin's backend via kandev proxy:
    // GET/POST {method} /api/plugins/{id}/... ; returns parsed JSON
    fetch(path: string, init?: RequestInit): Promise<Response>;
    // Backend API origin ("" when SPA and API share an origin) — for reaching
    // first-party kandev REST endpoints without re-deriving the split-origin
    // dev/desktop base URL from window internals.
    baseUrl: string;
  };
  ui: Record<string, unknown>;              // curated @kandev/ui components + app UI (see below)
  // The resolved light/dark theme, read live on every access. `host` is built
  // once per plugin load, so copying this into a variable that outlives a
  // render freezes it; read it during render, and pair it with onThemeChange
  // for anything that paints imperatively (canvas, inline SVG colors).
  readonly theme: "light" | "dark";
  // Fires on every light/dark change — the settings picker, its live preview,
  // and an OS prefers-color-scheme flip while the app is set to "system".
  // Returns an unsubscribe function; call it on teardown (component unmount,
  // KandevPlugin.destroy) or the listener outlives the surface that owns it.
  onThemeChange(listener: (theme: "light" | "dark") => void): () => void;
  // Soft SPA navigation (history push/replace + SPA re-render) — same code
  // path as the app router, so plugin pages can link into native routes
  // (e.g. /t/{taskId}) without a full reload.
  navigate(href: string, options?: { replace?: boolean }): void;
  // Imperatively opens a modal window rendered by the host's <PluginModalHost/>
  // (mounted once at the app root, isolated behind its own error boundary).
  // Independent of keybindings — any plugin code path may call it.
  openModal(options: PluginModalOptions): PluginModalHandle;
  // Sonner's imperative toast. The host mounts the single <Toaster/>, so
  // there is nothing to render and nothing to wire — and because it is
  // imperative rather than a component, it works from inside a plugin modal
  // regardless of which providers that modal sits under.
  toast: PluginToastApi;
  // Shared helpers — plain functions, so they live here rather than in `ui`
  // (a component map). See the "host.utils" section below.
  utils: PluginUtilsApi;
  // Authenticated, per-user key/value storage backed by
  // /api/plugins/{id}/user-state/... — see the "host.storage" section below.
  // Requires the plugin manifest to declare capabilities.user_state: true.
  storage: PluginStorageApi;
}

interface PluginStorageEntry { key: string; value: unknown; updatedAt: string; }

interface PluginStorageSetOptions {
  // Optimistic-concurrency guard: the updatedAt the caller last read. The
  // write is rejected (the returned promise rejects with a
  // PluginStorageConflictError) if the stored row was modified after this
  // time, leaving the stored value unchanged. Omit for unconditional
  // last-write-wins (the default).
  ifUnmodifiedSince?: string;
  // Identifies which logical surface made this write, for echo suppression
  // (see subscribe's writerId filter below). Appended to the host's own
  // per-tab id — not a replacement for it — so a static surface id like a
  // dockview panelId (shared by every tab that has that panel open) can't
  // make two different tabs look like the same writer to each other. Omit
  // to use the shared per-tab default alone — fine for a one-shot write with
  // no ongoing subscription (e.g. a kanban menu action). A surface that also
  // subscribes to its own writes (e.g. a task panel) should pass something
  // stable and unique to that surface, such as its own panelId from
  // PluginTaskPanelProps — otherwise its writes are indistinguishable from
  // any other surface of the same plugin in this tab, and one surface's
  // legitimate write can be silently swallowed by another surface's
  // subscription as if it were that other surface's own echo.
  writerId?: string;
}

// Mirrors the backend's user-state route scopes.
type PluginStorageScope = "instance" | "workspace" | "task" | "session" | "repository";

interface PluginStorageApi {
  get(scope: PluginStorageScope, scopeId: string, key: string): Promise<PluginStorageEntry | undefined>;
  set(
    scope: PluginStorageScope, scopeId: string, key: string, value: unknown,
    options?: PluginStorageSetOptions,
  ): Promise<{ updatedAt: string }>;
  delete(
    scope: PluginStorageScope, scopeId: string, key: string,
    options?: Pick<PluginStorageSetOptions, "writerId">,
  ): Promise<void>;
  // Every entry under (scope, scopeId), ordered by key. Not paginated.
  list(scope: PluginStorageScope, scopeId: string): Promise<PluginStorageEntry[]>;
  // Subscribes to live updates for this plugin's own storage made from
  // another tab, device, or surface — e.g. the kanban Edit modal and the
  // task panel both editing the same document. filter.scope/scopeId/key
  // narrow to a specific tuple; omit a field to match any value.
  //
  // filter.writerId, if given, must be the same value this surface passes to
  // set/delete's own writerId option — the host combines it with its own
  // per-tab id the same way on both sides, so a notification carrying that
  // resulting combined id is this surface's own echo and is skipped, and its
  // editor never clobbers its own caret/selection reacting to its own write.
  // Omit to fall back to the shared per-tab default alone: correct for a
  // plugin with only one surface, but two independent surfaces of the same
  // plugin (e.g. an open task panel and a kanban quick-action) both omitting
  // it would incorrectly suppress each other's legitimate writes as if they
  // were one surface's own echo.
  subscribe(
    filter: { scope?: PluginStorageScope; scopeId?: string; key?: string; writerId?: string },
    handler: (change: PluginUserStateChange) => void,
  ): () => void;
}

interface PluginUserStateChange {
  scope: PluginStorageScope;
  scopeId: string;
  key: string;
  updatedAt: string;
  deleted?: boolean; // true when the change was a delete rather than a set
}

interface PluginModalOptions {
  title?: string;                          // rendered in a DialogHeader/DialogTitle; omit for no header title
  content: React.ComponentType<{ slotProps?: unknown }>; // reuses the slot-component contract
  size?: "sm" | "md" | "lg" | "xl";         // maps to the host's Dialog width classes; default "md"
  dismissible?: boolean;                    // overlay click / Escape close the modal; default true
}

interface PluginModalHandle {
  close(): void; // closes this modal instance; no-op if already closed
}

host.ui contents: shadcn primitives (Accordion*, Alert*, Badge, Button, Card*, Checkbox, Collapsible*, Dialog*, DropdownMenu*, Empty*, Input, Kbd, KbdGroup, Label, Pagination*, Popover*, Progress, ScrollArea, Select*, Separator, Sheet*, Skeleton, Spinner, Switch, Table*, Tabs*, Textarea, Tooltip*, including TooltipProvider), the recharts wrappers (ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle), plus first-party app UI: PageTopbar (the kandev title bar, for routes that opt out of the default chrome and own their layout), TaskCreateDialog (kandev's real create-task modal, prefilled via initialValues), Combobox (the app's Command+Popover picker), and RichTextEditor/RichTextReadOnly (narrow wrappers over the Plan panel's tiptap markdown editor — see below). The authoritative list is apps/web/lib/plugins/host-api.ts (PLUGIN_UI).

Plugins must use these host instances — bundling copies of anything Radix/portal/context-based would split React context across instances and break refs/asChild. Pure-React libs (e.g. @tabler/icons-react) bundle fine.

The host wraps plugin routes, slots, and openModal content in a TooltipProvider, so a plain Tooltip works anywhere without one. TooltipProvider is exported for plugins that want their own delayDuration/skipDelayDuration over a dense cluster of tooltips; Radix supports nesting it.

Charts use the host's recharts. recharts is already a dependency of both apps/web and @kandev/ui, so the Chart* exports add no bundle weight — and a plugin must never bundle its own copy. recharts drives layout through its own React context and portals its tooltips, so a second copy splits that context exactly the way a second Radix copy does: charts render at zero size, tooltips attach to the wrong tree, and responsive containers stop resizing. Compose the Chart* wrappers with the chart primitives they hand you rather than importing recharts in plugin code.

host.toast and host.utils

// Sonner's imperative toast — the host owns the single <Toaster/>, so there
// is nothing to render and it works from any plugin code path, modals
// included.
host.toast.success("Synced 12 issues");
host.toast.error("Sync failed");

interface PluginToastApi {
  (message: string, options?: Record<string, unknown>): string | number;
  success(message: string, options?: Record<string, unknown>): string | number;
  // Renders the same toast as any other variant, and additionally logs
  // `[plugins] toast.error from "<pluginId>":` to the browser console.
  // It does NOT file a report into kandev's frontend error log — that log is
  // for kandev's own application errors, and a plugin toasting an expected
  // condition (a failed poll, say) would otherwise record an Error-level
  // entry every cycle. Console is where every plugin failure surfaces.
  error(message: string, options?: Record<string, unknown>): string | number;
  warning(message: string, options?: Record<string, unknown>): string | number;
  info(message: string, options?: Record<string, unknown>): string | number;
  // Dismisses one toast by the id a variant returned, or all of them when
  // called with no argument.
  dismiss(id?: string | number): unknown;
}

interface PluginUtilsApi {
  // The host's clsx + tailwind-merge combiner, so class merging matches the
  // components it styles.
  cn(...inputs: unknown[]): string;
  // Locale-aware relative time ("3 hours ago", "in 2 days", "yesterday") via
  // Intl.RelativeTimeFormat in the user's active locale; "" for unparseable
  // input. Prefer it over a hand-rolled ladder, which is English-only by
  // construction and goes untranslated for every non-English user.
  formatRelativeTime(value: string | number | Date): string;
}

Both are functions, so they sit beside navigate/openModal rather than in ui, which is a component map.

host.ui.RichTextEditor / host.ui.RichTextReadOnly

Pixel-identical to the Plan panel's markdown editor (paste handling, slash commands, drag handles, mermaid), so a plugin doesn't ship its own tiptap:

// RichTextEditor: editable, value/onChange round-trip markdown.
interface RichTextEditorProps {
  taskId: string; // required — scopes mermaid/image asset resolution
  value: string;
  onChange: (value: string) => void;
  placeholder?: string;
  className?: string;
  testId?: string;
}
// RichTextReadOnly: renders markdown read-only, no taskId dependency.
interface RichTextReadOnlyProps { value: string; className?: string; testId?: string; }

Deliberately narrow — not the plan editor's comments, onSelectionChange, onCommentClick, onCommentDeleted, or onEditorReady props, so the plan editor's internals can keep evolving without breaking this contract.

host.storage — authenticated per-user key/value storage

Backed by PUT/GET/DELETE /api/plugins/{id}/user-state/{scope}/{scopeId}/{key} and GET /api/plugins/{id}/user-state/{scope}/{scopeId} (list). Every read/write is scoped to the calling user via the session/PAT identity — two users writing the same (scope, scopeId, key) never see each other's value; a GET for another user's key returns 404. Requires the plugin manifest to declare capabilities.user_state: true (403 otherwise); an unknown or disabled plugin returns 404. scopeId/key must match [A-Za-z0-9][A-Za-z0-9._:-]{0,127}; the request body is capped (413 over the limit — see apps/backend/internal/plugins/user_state_handlers.go's maxUserStateBodyBytes). PUT accepts an optional ifUnmodifiedSince (compared against the stored row's updatedAt) — a conflicting write returns 409 and leaves the stored value unchanged.

This is entirely separate from the plugin-owned plugin_state table (written only by a plugin's own gRPC-connected backend via the Host SetState RPC) — host.storage needs no plugin backend at all, so a UI-only plugin bundle can persist data with zero Go code.

host.storage.set stamps a per-browser-tab writerId on every write (one id per page load, shared across every plugin in that tab). A successful PUT/DELETE publishes the plugin.user-state.updated WS action to the writing user's own connections only:

// WS message: { action: "plugin.user-state.updated", payload: PluginUserStateUpdatedPayload }
interface PluginUserStateUpdatedPayload {
  pluginId: string;
  scope: PluginStorageScope;
  scopeId: string;
  key: string;
  updatedAt: string;
  writerId?: string;
  deleted?: boolean;
}

The payload carries keys only, never the stored value — a subscriber refetches via host.storage.get. host.storage.subscribe(...) is a typed convenience wrapper over registry.registerWsHandler("plugin.user-state.updated", ...) that already filters to this plugin's own events, applies your scope/ scopeId/key filter, and skips notifications whose writerId matches this tab's own writes (so an editor never clobbers its own caret/selection from its own write).

registry: PluginRegistry

// icon: curated icon name (apps/web/lib/plugins/icons.ts — "ticket", "chart",
// "robot", "database", ...); unknown/missing names render a puzzle glyph in
// the sidebar.
// section: "main" (default) renders as a top-level sidebar entry;
// "integrations" renders inside the sidebar's Integrations section alongside
// the first-party integration links (GitHub, Jira, ...). Hosts predating a
// section value simply don't render items targeting it (additive change).
interface NavItem { id: string; label: string; path: string; icon?: string; section?: "main" | "settings" | "integrations"; }

// Configuration for the kandev-style title bar the host renders above a plugin
// route. All fields optional; defaults are derived (see registerRoute below).
interface PluginPageChrome {
  title?: string;      // default: nav-item label for the same path, else plugin name
  subtitle?: string;   // muted text next to the title
  icon?: string;       // curated icon name; default: matching nav item's icon
  backHref?: string;   // back-link target (host default "/")
  backLabel?: string;  // back-link label (host default "Kandev")
  actions?: React.ComponentType; // rendered on the right side of the topbar
}

interface PluginRouteOptions {
  // Default: enabled with derived title. Object → configure; false → render the
  // route full-bleed and own the chrome (e.g. with host.ui.PageTopbar).
  topbar?: boolean | PluginPageChrome;
}

interface PluginRegistry {
  // Top-level SPA route, e.g. "/jira". Component rendered by the SPA route resolver
  // when window.location path === path (exact match; trailing segments via ":param" not
  // required for v1 — exact + startsWith("/plugins/{id}") allowed). The host wraps the
  // page in kandev chrome (PageTopbar + scrollable content area) by default —
  // configure or opt out via options.topbar.
  registerRoute(path: string, Component: React.ComponentType, options?: PluginRouteOptions): void;

  // Sidebar/main nav entry. Rendered by <PluginNavItems/> in the app sidebar,
  // and by <MobilePluginNavSection/> in the phone menu sheet (the sidebar is
  // hidden below md), with item.icon resolved against the curated icon map
  // (fallback: puzzle).
  registerNavItem(item: NavItem): void;

  // Route under /settings/plugins/{id}/... rendered inside settings shell.
  // The settings shell already provides its own topbar chrome — no options here.
  registerSettingsRoute(path: string, Component: React.ComponentType): void;

  // Named slot injection. Host renders all components registered for a slot via
  // <PluginSlot name="..." slotProps={...}/>. Initial slots: "task-sidebar",
  // "settings-nav", "chat-input-actions", "chat-top-bar",
  // "main-top-bar", "app-status-bar-left", "app-status-bar-right",
  // "plugin-settings", "task-card-indicators", and "task-card-tags".
  // "task-card-indicators" renders a small icon/badge beside the PR status
  // icon on every kanban card and forwards
  // `{ taskId, workspaceId, workflowStepId }` as `slotProps`. Not a closed
  // union — hosts may register additional slot names.
  // "task-card-tags" renders in its own row on every kanban card, below the
  // badges row — for contributions too wide for the cramped title-row
  // "task-card-indicators" spot (e.g. a row of tag chips) — and forwards the
  // same `{ taskId: string, workspaceId: string | null, workflowStepId: string | null }`
  // shape as `slotProps` (`workspaceId` is null with no active workspace, and
  // `workflowStepId` is null when the task has no workflow step assigned).
  // "chat-input-actions" renders icon buttons in the chat composer toolbar
  // (beside the model picker, mic, and send) and forwards
  // `{ taskId, taskTitle, activeSessionId, sessionIds }` as `slotProps`.
  // "chat-top-bar" renders status in the session top bar (beside the
  // document/editor/debug controls) and forwards
  // `{ taskId, taskTitle, workspaceId, activeSessionId, sessionIds }`. Both
  // carry the active session plus every kandev session id on the task.
  // "main-top-bar" renders status/actions in the default app top bar on the
  // Home / Kanban / Tasks views (beside the CPU/DB metrics and the view/display
  // controls) and forwards `{ workspaceId, workspaceLabel, currentPage }`. It is
  // the app-wide, task-agnostic counterpart to "chat-top-bar", so it carries no
  // task/session ids.
  // Resolving a session id to an agent/ACP transcript id (e.g. to key
  // tokscale cost data on a session) is the plugin's job, done server-side in
  // the plugin backend via the Host data API; the host only propagates ids.
  // "plugin-settings" renders inline on a plugin's own settings page
  // (Settings > Plugins > <plugin>, at the top above the schema-driven settings
  // form) and forwards `{ pluginId: string, status: PluginStatus }`
  // as `slotProps`. It is owner-scoped: the host renders only the component
  // registered by the plugin currently being viewed, so your card appears on
  // your own settings page and never on another plugin's — no per-id gating
  // needed in your component.
  registerComponent(slot: string, Component: React.ComponentType<{ slotProps?: unknown }>): void;

  // WS action handler. Bridged into the existing lib/ws dispatch; called with the
  // decoded message payload for that action string.
  registerWsHandler(action: string, handler: (payload: unknown) => void): void;

  // Binds a handler to a keybinding declared in this plugin's manifest
  // (ui.keybindings[].id — { id, default, description }, see manifest schema).
  // The host resolves the effective combo (user override if the user
  // rebound it, else the manifest default) and dispatches globally, skipping
  // editable targets the same way core app shortcuts do. Combos are
  // user-overridable in Settings > Keyboard Shortcuts, namespaced
  // `plugin:{pluginId}:{id}`. Binding an id the manifest didn't declare still
  // stores the handler (a console warning is logged) since the dispatcher's
  // effective-shortcut resolution keys off the manifest list.
  //
  // Combo grammar (manifest `default` and any user override): `+`-separated
  // tokens, one of the modifiers `mod|ctrl|cmd|meta|alt|option|shift`
  // (repeatable) plus exactly one key token. `mod` resolves to Cmd on macOS
  // and Ctrl elsewhere (⌘/Ctrl). `shift` may not be combined with a digit or
  // symbol key (e.g. `shift+1`, `shift+slash`) — Shift changes the character
  // a browser reports for those keys, so the combo could never dispatch; both
  // the manifest validator and the frontend parser reject it.
  registerKeybinding(id: string, handler: (event: KeyboardEvent) => void): void;

  // Contributes a panel to the task workspace "+" (add panel) menu (dockview
  // desktop) and, when mobileEnabled, the phone bottom nav. Panel identity
  // lives in the dockview params (pluginId/panelKey); the panel id is
  // `plugin:{pluginId}:{panelKey}`. See "Task panels" below.
  registerTaskPanel(registration: TaskPanelRegistration): void;

  // Contributes an item to the kanban card's Edit submenu (group "edit") or
  // a flat, top-level card menu item between "Move to"/"Send to workflow"
  // and "Link" (group "primary"). See "Kanban card contributions" below.
  registerTaskMenuAction(registration: TaskMenuActionRegistration): void;

  // Contributes a client-side filter section to the kanban board's display
  // dropdown, alongside the built-in Workflow/Repository sections. See
  // "Task filters" below.
  registerTaskFilter(registration: TaskFilterRegistration): void;
}

type PluginPresentation = "desktop" | "mobile";

interface PluginTaskPanelProps {
  panelId: string; // this registration's panel id, so one Component can back multiple panels
  taskId: string;
  sessionId: string | null;
  presentation: PluginPresentation;
}

interface TaskPanelRegistration {
  id: string;          // plugin-local panel id (unique within the plugin, not globally)
  title: string;        // add-panel-menu row label and dockview tab title
  icon?: string;         // curated icon name (apps/web/lib/plugins/icons.ts)
  Component: React.ComponentType<PluginTaskPanelProps>; // wrapped in a PluginErrorBoundary
  mobileEnabled?: boolean; // include in the phone's grouped Panels picker. Default: false.
}

interface PluginTaskMenuContext {
  workspaceId: string;
  taskId: string;
  taskTitle: string;
  workflowStepId: string | null;
  presentation: PluginPresentation; // the actual kanban layout: desktop or mobile
}

interface TaskMenuActionRegistration {
  id: string;
  label: string;
  icon?: React.ReactNode;
  // "edit" nests the item in the card's Edit submenu; "primary" renders it
  // as a flat, top-level item between the "Move to"/"Send to workflow"
  // submenus and the "Link" submenu.
  group: "edit" | "primary";
  visible?(context: PluginTaskMenuContext): boolean; // default: always visible
  run(context: PluginTaskMenuContext): void | Promise<void>; // a rejection is caught and logged
}

interface PluginTaskFilterContext {
  taskId: string;
}

interface PluginTaskFilterOption {
  value: string;
  label: string;
  color?: string; // optional swatch color rendered beside the option label
}

interface TaskFilterRegistration {
  id: string;   // plugin-local filter id (unique within the plugin, not globally)
  label: string; // filter section label shown in the dropdown
  getOptions(): PluginTaskFilterOption[];
  // Called only when `selected` is non-empty — an empty selection is
  // implicit "All" and always matches without invoking this method.
  matches(context: PluginTaskFilterContext, selected: string[]): boolean;
}

App-status-bar slots

app-status-bar-left and app-status-bar-right are live named component slots. Each registration is one opaque status item; the slot chooses its default side, not a permanent side after user customization. Components receive slotProps with this exact shape:

interface AppStatusBarSlotProps {
  placement: "left" | "right";
  presentation: "bar" | "mobile-drawer";
  density: "full" | "compact";
  pathname: string;
  activeWorkspaceId: string | null;
  activeTaskId: string | null;
  activeSessionId: string | null;
}

placement matches registration slot. presentation identifies the mounted host; the host mounts only one presentation at once. density is full on desktop and phone drawer, compact on tablet. pathname and active IDs are current-context hints, not entity payloads; read complete records from host.store.

Before customization, registration order is render order within each default side. Users can Cmd-drag on macOS or Ctrl-drag elsewhere with a mouse to move any item across the whole desktop/tablet bar. Kandev stores that order in backend user settings; disabled contributions keep their place and return there when enabled. Phone renders the saved left sequence followed by the saved right sequence, without dragging. There is no cross-plugin priority API, keyboard-arrow ordering, or touch ordering. Enable, disable, and uninstall update slots without reload. Each component is isolated by an owner-aware error boundary, so plugins must tolerate remounting and render a compact bar control or touch-usable drawer row for the supplied presentation. The host neither inspects nor separately reorders children inside a registration, and does not add a nested interactive wrapper.

A full-bleed plugin route (topbar: false) opts out of host chrome. It may mount the host-provided Status drawer trigger when its own chrome should expose status; otherwise status access is intentionally its responsibility.

Task panels

registerTaskPanel adds one row to the task workspace's "+" (add panel) menu, after "Plan". Selecting it opens a dockview panel using a single generic "plugin-panel" dockview component shared by every plugin panel — panel identity lives in params: { pluginId, panelKey }, and the panel id is plugin:{pluginId}:{panelKey}. This keeps the host's panel-rendering dispatch to one branch per host release rather than one per plugin, and lets a saved layout round-trip a plugin panel reference even when that plugin is no longer installed: the layout manager drops (not throws on) an unresolvable plugin panel, and Settings > Layouts renders a generic placeholder box for one it can't render live.

Disabling or uninstalling a plugin closes any of its open panels in the current session and removes its add-panel-menu row; a panel your Component was actively rendering unmounts in place (no console error, no dockview exception). A plugin Component that throws during render shows a small "failed to load" fallback inside just that panel — the panel error boundary is scoped to your panel only, not the surrounding dockview layout.

On a phone viewport, mobileEnabled: true adds the panel to one grouped Panels bottom-nav action (after Terminal); it does not add one navigation item per panel. The touch-sized MobilePickerSheet presents every available panel in an internally scrolling list. Selecting a row dismisses the picker and renders your Component as the single full-height mobile surface with presentation: "mobile" — the same Component, no separate mobile registration. During a slow or failed reload, the host preserves a selected panel; after a ready generation, a panel omitted by the new registration is closed. An explicit disable or uninstall closes every panel owned by the plugin.

Kanban card contributions

With no plugin registered for group "edit", the kanban card's context/ dropdown menu shows the same flat Edit item as today. Once any plugin registers a registerTaskMenuAction({ group: "edit", ... }), that item becomes an Edit submenu: Edit task (the original action) first, then each visible plugin action in registration order. An action whose visible(context) returns false is filtered out entirely (not shown disabled). Selecting an action calls run(context); a rejected promise is caught and logged to the console, and the menu still closes either way (Radix's own close-on-select, independent of the async result).

Group "primary" renders each visible action as its own flat, top-level menu item instead of nesting it under Edit — positioned between the "Move to"/"Send to workflow" submenus and the "Link" submenu. Visibility filtering, registration order, and run()/error handling are identical to the "edit" group; the two groups are independent lists (an action only ever belongs to one).

"task-card-indicators" (documented above with the other slots) is the matching read-only surface: a small icon/badge rendered beside the PR status icon on every card, receiving { taskId, workspaceId, workflowStepId }.

"task-card-tags" is a second, sibling read-only surface for the same context — same { taskId, workspaceId, workflowStepId } shape — but mounted in its own row on the card instead of the cramped title row "task-card-indicators" shares with PRTaskIcon. Use it for a contribution that needs its own width, e.g. a row of tag chips.

Task filters

registerTaskFilter adds one section to the kanban board's display dropdown (the same menu that holds the built-in Workflow and Repository filters), rendered below Repository and above the Preview Panel section. Each registration's getOptions() supplies the section's checkbox list; the plugin owns option identity, ordering, and labels — including any "untagged"-style sentinel, which is just a normal option value the host does not special-case.

Selections are multi-select and purely client-side against tasks already loaded in the board's in-memory state: there is no backend query, pagination, or persistence for this filter, and selections reset on reload (unlike Workflow/Repository, which persist to backend user settings). An empty selection is implicit "All" for that section — matches() is only invoked once at least one option is selected, and multiple plugin filter sections combine with AND (a task must match every section with an active selection), mirroring how Workflow/Repository combine with the search query today. If matches() throws, the task is treated as non-matching and the error is logged (mirroring TaskMenuActionRegistration.visible's error handling).

Registry internals (host side)

apps/web/lib/plugins/registry.ts holds a singleton PluginRegistry whose data is reactive (a small zustand store or event emitter) so host React components re-render when registrations change. Every registration records the owning pluginId so the host can bulk-unregister on disable. Exposes read selectors: getRoutes() (each entry carries pluginId + options), getNavItems(), getSettingsRoutes(), getSlotComponents(slot), getWsHandlers(action), getPluginName(pluginId) (display name recorded by forPlugin(id, name), used for derived page-chrome titles), getTaskPanels() / getTaskPanel(pluginId, id), and getTaskMenuActions(group?). unregisterWsHandler(pluginId, action, handler) removes exactly one WS handler (used by host.storage.subscribe's returned unsubscribe) without disturbing the plugin's other registrations.

Plugin top-level routes render inside PluginPageFrame (apps/web/components/plugins/plugin-page.tsx): a PageTopbar title bar above a scrollable content area, resolved from options.topbar with derived defaults, or the bare component when the route opted out (topbar: false).

Integration points the app must add (task-19)

  • src/spa-routes.tsx: after the static route switch, before the not-found fallback, consult registry.getRoutes() for a matching path and render it inside the normal app shell.
  • src/settings-routes.tsx: consult registry.getSettingsRoutes() for /settings/plugins/{id}/* paths.
  • App sidebar (grep the nav list component): render <PluginNavItems/> reading registry.getNavItems().
  • lib/ws/router.ts / lib/ws/client.ts: after built-in dispatch, forward the message to any registry.getWsHandlers(action).
  • components/plugins/plugin-slot.tsx: <PluginSlot name props/> renders all slot components; drop into task detail sidebar + settings nav as initial hosts. The chat composer toolbar (components/task/chat/chat-input-toolbar-desktop.tsx and -mobile.tsx, via chat-input-plugin-actions.tsx) hosts the chat-input-actions slot, passing { taskId, taskTitle, activeSessionId, sessionIds }. kanban-card-plugin-slots.tsx hosts task-card-indicators beside PRTaskIcon and task-card-tags as its own row, both mounted from kanban-card-content.tsx's KanbanCardBody.
  • components/task/dockview-shared.tsx / dockview-panel-content.tsx / dockview-desktop-layout.tsx: the generic "plugin-panel" dockview component + pluginPanelTab, and renderPanel's "plugin-panel" case resolving { pluginId, panelKey } via registry.getTaskPanel(...). dockview-add-panel-items.tsx renders one "+" menu row per registry.getTaskPanels(). use-close-revoked-plugin-panels.ts closes an open panel whose registration disappeared.
  • components/task/mobile/session-mobile-bottom-nav.tsx / plugin-panel-picker.tsx / session-mobile-layout.tsx: expose all mobileEnabled registrations through one grouped Panels picker, and reconcile the focused panel against host lifecycle state; MobilePanelArea renders the selected plugin:{pluginId}:{panelKey} panel.
  • components/kanban-card-edit-submenu.tsx: builds the card's Edit entry from registry.getTaskMenuActions("edit").
  • lib/state/layout-manager/plugin-panels.ts: pluginPanelId/ parsePluginPanelId identity helpers plus registry-aware isKnownPanelId/isStructuralComponent/resolvePluginPanelDefinition, consulted by the layout manager's persistence/merge logic.
  • lib/plugins/host-api.ts / user-state-sync.ts: host.storage implementation (fetch against the user-state routes, per-tab writerId) and host.storage.subscribe (over registerWsHandler).
  • internal/gateway/websocket/user_notifications.go (backend): subscribes the plugin.user-state.updated bus event into the existing UserEventBroadcaster (user-scoped fan-out, same path as user.settings.updated).

Security posture (documented, enforced where cheap)

Plugin JS runs in the kandev origin with store access — this is the accepted tradeoff of option C. v1 mitigations: only active, operator-installed plugins load; bundles are served by kandev from the extracted package directory (same-origin, no third-party CDN, no upstream network hop); host wraps initialize in try/catch so a broken plugin can't crash boot; registrations namespaced + bulk-revocable per plugin. No credentials are ever displayed to the operator — installing a plugin (via URL or upload) has nothing to copy or reveal, unlike the old register flow's one-time API key/webhook secret. Sandboxing plugin JS (worker/realms) is explicit future work.

Example plugin must (task-21)

Ship a bundle that on initialize registers: a nav item "Hello" → route /plugins/hello rendering a native page (uses host.jsx + host.ui), a task-sidebar slot component, and a WS handler for task.created that updates a counter in its page via the plugin's own module state. No bundled React.