Custom UI
A plugin isn’t limited to tools and workflows — it can extend the Recursive dashboard itself with full-page views, detail panels, sidebar navigation, command-palette entries, custom chat renderers, and a reactive store wired to live server events.
Everything on this page is anchored to a real, shipping plugin: Xcode (plugins/personal/xcode/). It declares the full surface area, so wherever you want a working reference, open that folder and read the file the manifest points at.
What the Xcode plugin ships
Section titled “What the Xcode plugin ships”Its ui block touches every extension point at once:
{ "id": "xcode", "name": "Xcode", "ui": { "views": [ { "id": "xcode", "label": "Xcode", "icon": "smartphone", "route": "xcode", "component": "./ui/SimulatorViewer.svelte", "detailPanel": "./ui/BuildDashboard.svelte" } ], "nav": [ { "route": "xcode", "label": "Xcode", "icon": "smartphone", "shortcut": "Mod+8", "order": 80, "surface": "sidebar.nav.xcode" } ], "commands": [ { "id": "nav-xcode", "label": "Go to Xcode", "icon": "smartphone", "section": "Navigation", "type": "navigate", "route": "xcode", "shortcut": "Mod+8", "keywords": ["ios", "simulator", "swift", "build", "xcode"] } ], "panels": [ { "id": "xcode-panel", "label": "Xcode", "icon": "smartphone", "component": "./ui/XcodePanel.svelte", "order": 80 } ], "chatRenderers": [ { "kind": "xcode_build", "component": "./ui/XcodeBuildWidget.svelte" } ], "sseEvents": ["xcode"], "store": "./ui/xcode-store.ts" }}The components and store it references live alongside the manifest:
Directoryplugins/personal/xcode/
- plugin.json
Directoryui/
- SimulatorViewer.svelte main view (component)
- BuildDashboard.svelte detail panel for the view
- XcodePanel.svelte workspace panel
- XcodeBuildWidget.svelte chat renderer for
xcode_buildresults - xcode-store.ts reactive state + SSE subscription
- SigningPanel.svelte …and the rest of the feature UI
The rest of this guide walks the extension points in the order you’d typically add them.
A view is a full-page Svelte component that gets its own route and a slot in the sidebar. It’s the headline surface — “open my plugin and see a screen.”
{ "ui": { "views": [ { "id": "xcode", "label": "Xcode", "icon": "smartphone", "route": "xcode", "component": "./ui/SimulatorViewer.svelte", "detailPanel": "./ui/BuildDashboard.svelte" } ] }}| Field | Description |
|---|---|
id | Unique view identifier. |
label | Display name in navigation and tabs. |
icon | Icon name from the Recursive icon set. |
route | URL segment — xcode resolves to /xcode. |
component | Path to the main Svelte component. |
detailPanel | Optional component rendered in the detail pane beside the view. |
The component is a normal Svelte 5 component. The shell passes props in — SimulatorViewer.svelte is bindable on the current selection, loads state on mount, and tears its subscriptions down on destroy:
<script lang="ts"> import { onMount, onDestroy } from 'svelte'; import { simulators, selectedSimulator, bootedSimulators, loadXcode, stopScreenshotStream, } from './xcode-store.js';
let sSimulators = $state([]); let unsubs: (() => void)[] = [];
onMount(async () => { unsubs.push(simulators.subscribe((v) => (sSimulators = v))); await loadXcode(); });
onDestroy(() => { unsubs.forEach((u) => u()); stopScreenshotStream(); });</script>
<!-- view markup -->Navigation
Section titled “Navigation”nav adds the view to the sidebar so users can reach it without the command palette:
{ "ui": { "nav": [ { "route": "xcode", "label": "Xcode", "icon": "smartphone", "shortcut": "Mod+8", "order": 80, "surface": "sidebar.nav.xcode" } ] }}routematches the view’sroute, so clicking the nav item opens it.shortcutbinds a global keyboard shortcut (Modis ⌘ on macOS, Ctrl elsewhere).ordercontrols position in the sidebar. Built-in items use 10–40; plugins should use 50+ (Xcode sits at 80).surfaceis the named slot the entry mounts into.
Commands
Section titled “Commands”commands registers entries in the command palette (⌘K). For a navigable view this is usually a one-liner that jumps to the route:
{ "ui": { "commands": [ { "id": "nav-xcode", "label": "Go to Xcode", "icon": "smartphone", "section": "Navigation", "type": "navigate", "route": "xcode", "shortcut": "Mod+8", "keywords": ["ios", "simulator", "swift", "build", "xcode"] } ] }}keywords widen fuzzy matching — typing “simulator” surfaces the Xcode command even though the label is “Go to Xcode.”
Panels and chat renderers
Section titled “Panels and chat renderers”Two more surfaces let your plugin show up inside existing screens rather than on its own route.
Panels add a component to the workspace detail area, alongside the file viewer and preview:
{ "ui": { "panels": [ { "id": "xcode-panel", "label": "Xcode", "icon": "smartphone", "component": "./ui/XcodePanel.svelte", "order": 80 } ] }}Chat renderers replace the default JSON view of a tool result with a rich component. The Xcode plugin renders build results inline:
{ "ui": { "chatRenderers": [ { "kind": "xcode_build", "component": "./ui/XcodeBuildWidget.svelte" } ] }}When an agent’s xcode_build result lands in the chat feed, XcodeBuildWidget.svelte renders instead of raw JSON — turning a wall of error objects into a readable build summary. The kind matches the result shape your tool emits.
Store and live events
Section titled “Store and live events”A view usually needs reactive state, and that state usually needs to update when something happens server-side (a build finishes, a simulator boots). Two fields wire that up:
{ "ui": { "store": "./ui/xcode-store.ts", "sseEvents": ["xcode"] }}storeis a TypeScript module that owns the plugin’s frontend state. Components import from it and subscribe.sseEventslists the server-sent-event channels the store listens to. Declaring"xcode"lets the store react toxcodeevents the server broadcasts.
xcode-store.ts exports Svelte stores plus the loaders/actions components call, and subscribes to the SSE channel for live updates:
import { writable, derived } from 'svelte/store';import { api } from '../../../../apps/recursive/src/lib/api.js';import { onSSE } from '@recursive/core-shell/app/stores/sse';
export const simulators = writable<SimDevice[]>([]);export const bootedSimulators = derived(simulators, ($s) => $s.filter((d) => d.state === 'Booted'),);
export async function loadXcode() { const res = await api.get('/api/xcode/status'); simulators.set(res.simulators);}
// React to server-pushed `xcode` events declared in sseEventsonSSE('xcode', (evt) => { // update stores in place as builds run, simulators boot, etc.});Entity types
Section titled “Entity types”If your plugin owns a kind of object users should be able to @-mention and search, register it under entityTypes so mentions and global search resolve it:
{ "ui": { "entityTypes": [ { "type": "goal", "storeKey": "allGoals", "nameKey": "title" } ] }}storeKey points at the store collection to read from, and nameKey is the field used as the display label. (The Xcode plugin doesn’t register entity types; this is the shape when you need it.)
Svelte component rules
Section titled “Svelte component rules”- Use CSS classes from
src/app.cssfor styling — component<style>blocks are for overrides only. - Follow the 38px compact header pattern for view headers; headers are transparent on a single unified scroll surface.
- Add tooltips to every icon-only button.
- Use SVG icons from the Recursive icon set — never emoji — in interactive UI.
- Keep popovers, dropdowns, and tooltips viewport-aware.
- User preferences (theme, model, selection) live server-side, not in
localStorage.
Full field reference
Section titled “Full field reference”This page is the narrative path; for the exhaustive list of every ui.* field and its type, see the Manifest Reference.
What’s next
Section titled “What’s next”- Getting Started → Add a workspace view — build a view from scratch.
- Manifest Reference — every
plugin.jsonfield explained. - MCP Tools — the tools your UI calls into.