Skip to content

Plugins

A Nuclei plugin is a single folder on your disk containing two files: a plugin.json manifest and a single, dependency-free ES module whose activate(api) export registers what the plugin contributes — a custom panel, a theme, a Dirac skill, a gate renderer. Nuclei reads the folder, validates the manifest, loads the module, and hands it a PluginAPI. There is no build step, no bundler, no npm install, and no remote registry: you point Nuclei at a folder and it runs.

The Plugins view lives in the activity bar in Research mode. It has a “Load plugin…” and a “Create plugin…” button, an Installed tab, and a Panels tab that renders enabled panels and lists enabled themes.

my-plugin/
├── plugin.json # the manifest — validated before anything loads
└── entry.js # the ES module — exports activate(api)

The manifest’s entry field names the module file ("entry.js" by convention, but any relative path inside the folder works). Everything the module needs arrives through api — there is no bare import, because the module is loaded from a blob URL that has no module resolver.

plugin.json is validated against a zod schema (src/plugins/manifestSchema.ts) before the entry module is ever read. A malformed file fails the load with a field-addressed error and registers nothing.

{
"name": "hello-panel",
"version": "1.0.0",
"description": "Live panel showing the current circuit's qubit count and depth.",
"author": "Nuclei",
"entry": "entry.js",
"capabilities": ["custom-panel"],
"permissions": ["read-circuit"]
}
FieldTypeRule
namestringRequired. Kebab-case: ^[a-z0-9][a-z0-9-]*$ (lowercase letters, digits, hyphens). This is the plugin’s identity — installs are deduplicated by it.
versionstringRequired. Semver x.y.z (^\d+\.\d+\.\d+$).
descriptionstringOptional, defaults to "".
authorstringOptional, defaults to "".
entrystringRequired. Relative path to the ES module, inside the folder. Absolute paths (/…, C:\…, \\…) and any .. segment are rejected — the entry can’t escape the plugin folder.
capabilitiesarrayRequired, non-empty. One or more of custom-panel, gate-renderer, kernel-extension, dirac-skill, theme. Gates what the plugin may register (see below).
permissionsarrayOptional, defaults to []. Any of read-circuit, read-results, read-editor, write-editor. Gates what the plugin may read.

A capability must be declared before the matching register… call does anything — calling api.registerPanel(…) without custom-panel in capabilities is a silent no-op.

CapabilityUnlocksStatus in v1
custom-panelregisterPanelLive. Rendered in the Panels tab.
themeregisterThemeLive. Appears in Panels → Themes with Apply / Clear.
dirac-skillregisterDiracSkillStored, not yet injected into Dirac’s prompt (planned for v1.1).
gate-rendererregisterGateRendererAccepted and gated, not yet consumed by the circuit renderer (planned for v1.1).
kernel-extensionReserved vocabulary; no API method yet.

Permissions gate the read side of the API. Without the matching permission, the getter returns an empty value and the subscription is a no-op — the plugin still loads and runs.

PermissionGrantsDenied behavior
read-circuitgetCircuitSnapshot(), onCircuitChange()getter returns null; subscribe is a no-op
read-resultsgetSimulationResult(), onResultChange()getter returns null; subscribe is a no-op
read-editorgetEditorCode()getter returns ''
write-editorreserved; no write method on the API yet

The entry module exports an activate function — as a named export or the default export (named is tried first). Nuclei calls it once with the plugin’s api. activate may return a cleanup function that runs when the plugin is disabled, uninstalled, or reloaded.

// entry.js — a single-file, dependency-free ES module.
export function activate(api) {
api.registerPanel({
id: 'my-panel',
title: 'My Panel',
render(container) {
container.textContent = 'hello';
// Optional: return a cleanup run when the panel unmounts.
return () => { /* teardown DOM listeners, timers, etc. */ };
},
});
// Optional: return a cleanup run on disable / uninstall / reload.
return () => api.log('deactivated');
}

The signature is (api: PluginAPI) => (() => void) | void. Everything the plugin does happens through api; there is no other entry point.

The full object passed to activate (src/plugins/types.ts):

MethodRequiresReturns
getCircuitSnapshot()read-circuitthe current CircuitSnapshot, or null
getSimulationResult()read-resultsthe last SimulationResult, or null
getEditorCode()read-editorthe active editor buffer’s text, or ''
getFramework()the active framework id ('qiskit', 'cirq', 'cuda-q', 'qsharp')
MethodRequiresBehavior
onCircuitChange(cb)read-circuitcalls cb(snapshot) whenever the circuit changes; returns an unsubscribe function
onResultChange(cb)read-resultscalls cb(result) whenever a new simulation result arrives; returns an unsubscribe function

Both return an unsubscribe function. If the permission is missing the returned unsubscribe is a harmless no-op, so you can call it unconditionally in your cleanup.

MethodRequiresNotes
registerPanel({ id, title, render })custom-panelrender(container) mounts imperative DOM into a host element and may return a cleanup run on unmount.
registerTheme({ name, colors })themecolors is a Record<string, string> of token → CSS color; see theme keys.
registerDiracSkill({ name, systemPromptFragment, tools? })dirac-skillStored; not injected into Dirac in v1.
registerGateRenderer({ gateName, render })gate-rendererrender(ctx, x, y, size) draws onto a 2D canvas context. Accepted but not consumed in v1.
MethodNotes
log(message)Prefixed console.logonly in dev builds (import.meta.env.DEV). Silent in a packaged app.

render receives a fresh HTMLElement to mount into. Build DOM imperatively, subscribe to state, and return a cleanup:

export function activate(api) {
api.registerPanel({
id: 'qubit-counter',
title: 'Qubit Counter',
render(container) {
const el = document.createElement('div');
el.style.cssText = 'padding:12px;font:12px system-ui';
const paint = (snapshot) => {
el.textContent = snapshot
? `Qubits: ${snapshot.qubit_count} · Depth: ${snapshot.depth}`
: 'No circuit yet — start typing quantum code.';
};
paint(api.getCircuitSnapshot());
const unsubscribe = api.onCircuitChange(paint);
container.appendChild(el);
return unsubscribe; // runs when the panel unmounts
},
});
}

Each panel is mounted in its own error-isolated host: if your render throws, Nuclei shows an inline error card for that panel instead of taking down the app.

A theme plugin registers a named color set. Nuclei intersects your keys against its own theme tokens — unknown keys are ignored, so you only need to override the tokens you care about. The user applies it from Panels → Themes (Apply / Clear).

export function activate(api) {
api.registerTheme({
name: 'Ocean',
colors: {
bg: '#08131f',
bgPanel: '#0b1a2b',
bgElevated: '#102438',
accent: '#00B4D8',
accentLight: '#48cae4',
},
});
}

Recognized token keys: bg, bgPanel, bgEditor, bgElevated, border, borderStrong, text, textMuted, textDim, accent, accentLight, dirac, success, warning, error. Values are any valid CSS color.

The fastest way to start is Create plugin… in the Plugins view (Research mode):

  1. Click Create plugin…, type a display name, and click Choose folder….
  2. Nuclei slugifies the name to a kebab-case id, creates `<folder>`/`<slug>`/ with a plugin.json and a working entry.js (a live circuit panel), and refuses to overwrite an existing plugin folder.
  3. The entry.js opens in the editor so the code is never a black box, and the plugin loads immediately — its panel appears live in the Panels tab and updates as you edit quantum code.

Edit entry.js on disk, then hit Reload on the Installed tab to pick up your changes — that’s the tight authoring loop.

To load a plugin you already have on disk, click Load plugin… and pick its folder. Nuclei validates the manifest, loads the entry module, and activates it. On failure it shows a field-addressed error card and registers nothing.

Installed plugins persist across restarts: Nuclei stores each folder path and its enabled state, and re-loads them on boot (enabled plugins are re-activated; disabled ones are listed manifest-only; a folder that has gone missing or broken becomes an honest error record rather than a silent drop).

On the Installed tab each plugin shows its version, author, capabilities, and a status pill, with Enable / Disable, Reload, and Uninstall actions. Disabling runs the plugin’s cleanup and removes its panels and themes; enabling re-activates it from disk.

The repo ships two runnable examples under examples/plugins/. The smallest, hello-panel, is a custom panel that mirrors the live circuit.

examples/plugins/hello-panel/plugin.json:

{
"name": "hello-panel",
"version": "1.0.0",
"description": "Live panel showing the current circuit's qubit count and depth.",
"author": "Nuclei",
"entry": "entry.js",
"capabilities": ["custom-panel"],
"permissions": ["read-circuit"]
}

examples/plugins/hello-panel/entry.js:

export function activate(api) {
api.registerPanel({
id: 'hello-panel',
title: 'Hello Panel',
render(container) {
const el = document.createElement('div');
el.style.cssText = 'padding:12px;font:12px system-ui,sans-serif;line-height:1.6';
const paint = (snapshot) => {
if (!snapshot) {
el.textContent = 'No circuit yet — start typing quantum code.';
return;
}
el.innerHTML =
`<strong>Qubits:</strong> ${snapshot.qubit_count}<br>` +
`<strong>Depth:</strong> ${snapshot.depth}<br>` +
`<strong>Gates:</strong> ${snapshot.gates.length}<br>` +
`<strong>Framework:</strong> ${snapshot.framework}`;
};
paint(api.getCircuitSnapshot());
const unsubscribe = api.onCircuitChange(paint);
container.appendChild(el);
return unsubscribe; // cleanup on panel unmount
},
});
api.log('hello-panel activated');
}

To try it: Load plugin… → pick examples/plugins/hello-panel. The panel appears in the Panels tab and updates on every keystroke. It declares only read-circuit, so getSimulationResult() would return null — grant read-results in the manifest if you want to react to executions.

The sibling examples/plugins/ocean-theme exercises the theme path: load it, then Apply it from Panels → Themes to recolor the UI.

What Nuclei does guarantee is protection from broken plugins, not malicious ones:

  • Manifest validation — a bad plugin.json fails the load cleanly.
  • Path-traversal guardentry can’t point outside the plugin folder.
  • Per-plugin error isolation — a panel that throws shows an error card; an activate that throws marks the plugin errored and registers nothing, rather than crashing the app.

Because there is no remote registry and plugins load only from a folder you explicitly pick, the security posture is the same as running a local script: only load plugins you wrote or trust. Real isolation (Worker/iframe message passing) is future work — Nuclei would rather be honest about the boundary than ship a fake sandbox.

Plugins are for UI and read-only integrations that live outside the tree. To add a whole quantum framework or a hardware backend, extend the kernel directly: framework adapters, hardware providers, and contributing.