Plugin Frontend Extensions
A plugin pack can ship a JavaScript bundle alongside its Python nodes. When the CodefyUI editor loads, it discovers and imports that bundle as an ES module, giving the plugin access to a stable JavaScript API for UI, graph manipulation, and proxied HTTP.
:::note Availability
Frontend extensions are in CodefyUI 1.3.0 and later. Check cdui --version; if it reports an older version, run cdui update.
Dock panels, toolbar buttons, execution events and the runs facade need apiVersion 3 (CodefyUI 2.0.0 and later); graph.getView needs apiVersion 4 (CodefyUI 2.3.0 and later); api.workspace and the six agent canvas operations need apiVersion 5 (CodefyUI 2.5.0 and later). Feature-check before you use them — see API versions.
:::
API versions
api.apiVersion is a number that only ever grows, and every release has been additive in shape: nothing that worked at an older version has been removed or changed signature. A plugin written for apiVersion 2 keeps working on an apiVersion 5 editor with no changes at all, with one exception that apiVersion 5 introduces and that is written down in full below — a read-only tab now refuses a write. Since CodefyUI 2.8.4 the editor can also have no tab open, with apiVersion still 5; When no tab is open lists what the API returns then.
apiVersion | CodefyUI | Added |
|---|---|---|
| 1 | 1.3.0 | ui.addFloatingWidget, ui.toast, graph.*, http.fetch, storage.* |
| 2 | 1.3.0 | nodes.registerRenderer |
| 3 | 2.0.0 | ui.addPanel / removePanel, ui.addToolbarButton / removeToolbarButton, events.onExecution, runs.* |
| 4 | 2.3.0 | graph.getView — which level of the graph the user is looking at |
| 5 | 2.5.0 | workspace.* — tabs, snapshots and compare-and-swap writes; move_node, set_segment / remove_segment, add_note / update_note, set_node_meta |
Check it before reaching for anything newer than the version you require, and degrade rather than throw:
export default function activate(api) {
if (api.apiVersion >= 3) {
mountDashboard(api.ui.addPanel({ id: "dash", title: "Dashboard" }));
} else {
mountDashboard(api.ui.addFloatingWidget({ id: "dash" }));
}
}
Because the additions are additive, a breaking change would come with an apiVersion bump and a migration note — never silently.
Declaring a frontend entry point
Add a [frontend] section to cdui.plugin.toml:
[plugin]
id = "my-plugin"
name = "My Plugin"
version = "0.1.0"
requires_codefyui = ">=1.3.0"
[frontend]
entry = "frontend/index.js"
requires_codefyui is advisory metadata (it is recorded but not currently enforced at install time); set it to the first CodefyUI release that ships the features your plugin depends on — frontend extensions landed in 1.3.0.
The entry path must be relative to the plugin root and must live under frontend/. The file must be a valid ES module with a default export (see The activate contract below).
How the editor serves and discovers the bundle
The backend serves an enabled plugin's frontend/ directory at:
/plugins/<plugin-id>/frontend/<file>
These directories are not mounted at startup. For each request, the route resolves the plugin directory from the lockfile. A bundle therefore becomes available immediately after installation or reload and returns 404 immediately after the plugin is disabled or uninstalled; neither change requires a restart. Frontend files are served only for enabled plugins whose manifest declares [frontend].entry; a frontend/ directory alone is insufficient. Responses use Cache-Control: no-cache, which makes the browser revalidate the file and obtain updates on the next load. The backend serves assets/ through /plugins/<plugin-id>/assets/<file> under the same enablement rule, using GET and HEAD without requiring a manifest entry.
The plugin listing endpoint exposes the entry point so the editor can load it:
GET /api/plugins
Example response excerpt:
{
"id": "my-plugin",
"name": "My Plugin",
"version": "0.1.0",
"frontend_entry": "/plugins/my-plugin/frontend/index.js"
}
frontend_entry is null when the manifest has no [frontend] entry, the declared file is missing, or the plugin is disabled. Disabled plugins provide no UI, frontend files, or assets. The editor loads the module only when frontend_entry is non-null.
The activate contract
Your bundle must export a single default function named activate. The editor calls it with the CodefyUIPluginAPI object:
// frontend/index.js
export default function activate(api) {
// api is a CodefyUIPluginAPI instance
}
At startup the editor waits for the node catalog to load (up to 15 seconds), then imports and activates the plugins one at a time. It activates all of them again after any Plugin Center install, update, uninstall, enable or disable, not only the plugin concerned, and after a linked plugin's hot reload. Each time it first removes everything the plugins registered through the API (panels, toolbar buttons, node renderers, subscriptions, floating widgets), then imports each enabled bundle again as a fresh module and calls its default export with a new API object. There is no deactivate hook, and timers, listeners or DOM that a plugin created outside the API are not cleaned up, so keep long-lived work behind API registrations or panel onShow/onHide.
The editor does not await activate's return value — do your setup synchronously (you may still start async work; the editor just won't wait for it). Errors thrown synchronously inside activate are caught per-plugin, logged to the browser console, and surfaced as a toast; they cannot crash the editor or other plugins. The import is also bounded by a 10-second timeout. (Only the default export being a function is required; the name activate is convention.)
CodefyUIPluginAPI reference
| Member | Type | Description |
|---|---|---|
apiVersion | number | The API version this editor implements; see API versions. |
pluginId | string | This plugin's id exactly as its manifest writes it, hyphens included. Use it for WorkspaceSource.pluginId, and to recognise your own writes through event.origin.pluginId. |
api.ui — editor UI
| Method | Signature | Description |
|---|---|---|
addFloatingWidget | ({ id }) => HTMLElement | Create (or reuse) a container <div> in the editor's floating-widget stack and return it. id must be unique per plugin. You own the returned element — fill it with your own DOM, or mount a React root into it. |
toast | (message, level?) => void | Show a transient notification. level is "info" (default), "success", "warning", or "error". |
addPanel | (opts) => HTMLElement | apiVersion 3. Register a dock panel and return its container element. |
removePanel | (id: string) => void | apiVersion 3. Remove one of your panels. |
addToolbarButton | (opts) => () => void | apiVersion 3. Add a toolbar button; returns a remove function. |
removeToolbarButton | (id: string) => void | apiVersion 3. Remove one of your buttons by id. |
Dock panels
Requires api.apiVersion >= 3.
interface PluginPanelOptions {
id: string; // unique within your plugin
title: string; // tab label, or right-hand section heading
icon?: string; // short glyph shown before the title
dock?: "bottom" | "right"; // defaults to "bottom"
onShow?: () => void; // the element was attached to the document
onHide?: () => void; // ...and detached again
}
A "bottom" panel becomes a tab in the editor's bottom dock, after Execution Log, Training and Runs. A "right" panel becomes a section in the right-hand column, alongside the node config and inspector panels. The host owns the tab chrome, the ordering and the placement; you own everything inside the element.
The element is yours for the life of the panel. Its identity never changes, so mount into it exactly once:
const el = api.ui.addPanel({ id: "runs", title: "My Runs", icon: "~" });
createRoot(el).render(<MyPanel />); // once, not per tab switch
The editor mounts only the active dock tab, so the container your panel sits in is torn down and rebuilt as the user moves between tabs. Your element is not: the editor detaches it and re-attaches it, with its children and their state intact. Calling addPanel again with the same id returns the same element and just updates the title, icon and dock.
What that costs you is the one thing to be careful about: your code keeps running while the panel is off screen, rendering into an element that is not in the document. If the panel does anything expensive — a chart, a poll, an animation — gate it:
api.ui.addPanel({
id: "runs", title: "My Runs",
onShow: () => chart.start(),
onHide: () => chart.stop(),
});
Write both callbacks so that calling them twice in a row is harmless: React's development mode replays mount effects, so a single tab switch can produce an extra onHide/onShow pair.
Panels are removed automatically when your plugin is unloaded or hot-reloaded; removePanel is for panels you want gone earlier.
Toolbar buttons
Requires api.apiVersion >= 3.
interface PluginToolbarButtonOptions {
id: string; // unique within your plugin
icon: string; // short glyph — the toolbar has room for a glyph
tooltip: string; // hover and accessible text; an icon is not a label
onClick: () => void;
}
const remove = api.ui.addToolbarButton({
id: "sweep", icon: "~", tooltip: "Start a sweep",
onClick: () => startSweep(),
});
Buttons land in one group at the right of the toolbar, in registration order. There is no way to ask for a position, and the editor decides how many are shown: on a wide window up to three sit inline, and on a narrow one they collapse into a single overflow menu. That is what keeps five installed plugins from pushing Run off the toolbar, so write the tooltip as if it were the label — in the menu, it is.
If onClick throws, the editor logs it and carries on; the toolbar is not affected.
Re-adding an id replaces the button. The remove function you get back belongs to that one registration, so if you have since replaced the button, calling the older remove function does nothing rather than taking the replacement down with it. When you mean "remove whatever is under this id", call removeToolbarButton(id). Buttons are removed automatically when your plugin is unloaded or hot-reloaded.
api.graph — graph read and write
| Method | Signature | Description |
|---|---|---|
getGraph | () => SerializedGraph | Return the whole graph (nodes, edges, params, block definitions under subgraphs, and settings when the graph assigns a device) — always the top level, whatever the user has open. Treat it as read-only; see below. |
getNodeDefinitions | () => NodeDefinition[] | Return the full node palette: types, port schemas, param schemas. |
applyOperations | (ops: GraphOp[]) => ApplyResult | Apply a batch of graph operations synchronously (returns the result directly — not a Promise). The whole batch is committed as at most one undo step, and it applies to the canvas the user has open — see Which level the user is looking at. |
onGraphChanged | (callback: () => void) => () => void | Subscribe to graph changes — including the user stepping into or out of a block. The callback takes no arguments; call getGraph() from it. Returns an unsubscribe function. |
getView | () => GraphView | apiVersion 4. Read-only: which level of the graph the user is looking at. |
getGraph() returns a new top-level object, but not a deep copy: the params of a node with no secret param, segmentGroups, and preset and block definitions are the editor's live objects. Do not modify the result; change the graph only through applyOperations. Secret params, such as the API-key fields of LLM nodes, come back as "", so getGraph() does not hand a plugin a key typed into the editor. This does not hide the key from a plugin: the key is still in the editor page, where plugin JavaScript runs (see Trust model). workspace.snapshot().graph is built the same way. The settings object is described in The graph's settings object; assigning or clearing the graph's device fires onGraphChanged, raises the tab's revision, and reaches workspace.onChanged as a graph event.
GraphOp table
All thirteen operation types share the property op (the discriminant string). Field names below are exact.
op | Fields | Description |
|---|---|---|
"add_node" | node_type: string, ref?: string, params?: Record<string, unknown>, position?: { x: number; y: number } | Add a node of the given type. ref is a caller-chosen alias that later ops in the same batch can use in place of the generated node id. position defaults to a staggered layout. |
"connect" | source: string, source_handle: string, target: string, target_handle: string | Connect an output handle to an input handle. source/target accept a node id or a ref from an earlier add_node. Use source_handle: "trigger" for a trigger edge. An input takes one source: when target_handle already has an edge, it is replaced and replaced_edge_ids names it. Connecting the same two handles twice fails. |
"set_params" | node_id: string, params: Record<string, unknown> | Merge parameter values into a node. |
"remove_node" | node_id: string | Remove a node and all edges connected to it. |
"remove_edge" | source: string, target: string, source_handle?: string, target_handle?: string | Disconnect matching edge(s) between two nodes. |
"clear_graph" | (none) | Remove all nodes and edges. |
"auto_layout" | (none) | Lay out every node except notes, the same way Layout All in the toolbar's Auto Layout menu does. A note bound to a node moves with it; an unbound note stays where it is. When the batch adds, removes or moves a node other than a note, the view is fitted to the laid-out nodes and the notes bound to them: at once on the tab the user is looking at, and on the next switch to a tab in the background, in place of the pan and zoom it had. A handler may lay out a tab and switch tabs in the same turn, in either order. |
"move_node" | node_id: string, position: { x: number; y: number } | apiVersion 5. Put one node at an exact position. Any note bound to that node moves with it, exactly as it does when the user drags the node. |
"set_segment" | segment_id?: string, head_node_id: string, tail_node_id: string | apiVersion 5. Create or replace a segment overlay — the bubble the editor draws around every node on a data path from head to tail. Omit segment_id to create; pass an existing one to move it. The result carries the id either way, in segment_id. Fails when no data-edge path joins the two, and when either end is a note. |
"remove_segment" | segment_id: string | apiVersion 5. Remove a segment overlay. |
"add_note" | ref?: string, text: string, position?: { x: number; y: number }, color?: string, bind_to?: string | apiVersion 5. Add a text note. text is 1 to 4000 characters and may hold newlines and tabs but no other control characters; color is #rrggbb; bind_to attaches the note to a node so it follows that node, and defaults its position beside it. Notes are never executed, exported or validated. |
"update_note" | node_id: string, text?: string, color?: string | apiVersion 5. Rewrite an existing note. At least one of text and color is required, and text applies to a text note only — an image note's content is its data URL. |
"set_node_meta" | node_id: string, label: string | apiVersion 5. Name a node. 1 to 120 characters on one line, trimmed. The label is stored beside params, never inside it, and now survives save and reload. |
ApplyResult shape
interface OpResult {
index: number; // the op's position in the batch
ok: boolean; // whether this op applied
error?: string; // failure reason when ok is false
node_id?: string; // resolved node id, from ops that create or edit one; not remove_node
segment_id?: string; // apiVersion 5: the id set_segment created or replaced
replaced_edge_ids?: string[]; // the edges connect replaced on an input that already had one
}
interface ApplyResult {
results: OpResult[]; // one entry per op, in input order; empty when no tab is open
refs: Record<string, string>; // ref alias -> generated node id
node_count: number; // node count after the batch
edge_count: number; // edge count after the batch
}
Batch semantics: All ops in a single applyOperations call form at most one undo step — pressing Ctrl+Z after an AI edit undoes the entire batch at once, and a batch that leaves the graph as it was adds none. Ops are applied in order; a failing op is skipped and reported in its results entry (ok: false plus an error), while the remaining ops continue. A ref alias created by an earlier add_node in the same batch is available to later ops, and is echoed back in refs.
Which level the user is looking at
Requires api.apiVersion >= 4.
A CodefyUI graph nests. A block (subgraph) has a canvas of its own, and the user can step inside one — the bar above the canvas then reads Main > Encoder. There is only ever one canvas: stepping inside swaps the block's insides onto it, which is exactly why every editing tool works the same inside a block as outside it.
For a plugin that has one consequence, and it decides where your edits land:
getGraph()always answers with the whole graph. The editor folds whatever is open back in before serializing, the same way Save and Run do, so you read the same bytes the user would get by saving the file.applyOperations()writes to the canvas the user has open. Inside a block,add_nodeadds a node to that block, andclear_graphempties the block rather than the graph. Node ids you read fromgetGraph()do not exist there, so ops naming them come backok: falsewith an error.
So a plugin that reads, reasons, then writes can be right about the graph and still write somewhere the user is not looking. getView() is how you tell the two situations apart first:
interface GraphViewLevel {
subgraphId: string; // the block definition's id, as getGraph() refers to it
name: string; // the block's name, as the breadcrumb bar shows it
instanceId: string; // the block node the user stepped into, on the level above
}
interface GraphView {
depth: number; // 0 at the top level, 1 inside a block, 2 inside a block inside a block
path: GraphViewLevel[]; // the open blocks, outermost first; empty at the top level
atTopLevel: boolean; // depth === 0, for the check you usually want
}
Two copies of one block share a subgraphId; instanceId tells them apart. It also names a node's id in a run: a run flattens blocks, so a node n on the open level runs as the instanceIds of path joined with /, then n (blk/n, or blk/nest/n two levels down), and /api/execution/outputs keeps its captures under that id. Read them with the query form, ?node_id=. An editor before CodefyUI 2.8.10 also reports apiVersion 5 but has no instanceId, so check 'instanceId' in level.
const view = api.graph.getView();
if (!view.atTopLevel) {
const inside = view.path[view.path.length - 1].name;
api.ui.toast(`Step out of "${inside}" first — an edit now would land inside that block.`, "warning");
return;
}
api.graph.applyOperations(ops);
Refusing is not the only honest answer — waiting, or scoping the edit to something that makes sense inside a block, are both fine. The point is that the choice is now yours to make instead of a coin flip.
The view is read-only, and read live: each call is a fresh answer, and there is deliberately no way to navigate somebody's editor from a plugin. onGraphChanged fires when the user steps into or out of a block (the canvas changed, after all), so a panel that displays where it would write can re-read getView() from that callback.
Where a write lands is the editor's long-standing behaviour, now written down rather than changed. A later revision may let an op name its target level explicitly; it will do that by adding something, not by quietly redirecting the writes that installed plugins already make.
api.workspace — tabs and compare-and-swap writes
Requires api.apiVersion >= 5. On an older editor api.workspace is undefined — it is never stubbed with methods that throw, so typeof api.workspace?.openGraphs === "function" is an honest check.
api.graph sees one graph: the one in front of the user. api.workspace sees the tab strip. It exists for the plugin that wants to put a proposal somewhere the user can look at it without touching what they were doing, and to write back only if they have not changed it meanwhile.
| Method | Signature | Description |
|---|---|---|
openGraphs | (entries, options?) => WorkspaceOpenResult[] | Open one or more graphs as editor tabs. The result is positional: result[i] describes entries[i], and one bad entry never affects another. |
tabs | () => WorkspaceTabInfo[] | Every tab, in strip order, each with its own live view; active marks the one the user is looking at. |
snapshot | (tabId?) => WorkspaceSnapshot | A tab's identity, live view, and whole graph. No id means the active tab. An unknown id returns { error: "unknown_tab" } rather than throwing. |
applyOperations | (request) => WorkspaceApplyResult | Apply a batch to a named tab, optionally only if its revision still matches, optionally all-or-nothing. |
onChanged | (callback) => () => void | Subscribe to tab and document changes across every tab. Returns an unsubscribe function. |
Revisions
Every tab carries a revision: a number that starts at 1 and goes up by one every time the tab's document changes. Dragging a node changes it, and so does assigning or clearing the graph's device. So do undo and redo — they restore older content, which is still a change — and stepping into or out of a block, which swaps the tab's canvas. Renaming the tab, switching to it, selecting a node, panning the canvas and highlighting a segment do not — and neither does a run, whose per-node status, error and progress are painted on the canvas but never written to the saved file. A compare-and-swap therefore does not expire several times a second while a model trains.
The number only ever climbs, and it is saved with the tab, so a revision you stored before a reload still means something afterwards. That is the whole point: hold a revision, go away and think for two minutes, and hand it back with your write.
interface WorkspaceTabInfo {
tabId: string;
title: string;
revision: number;
readOnly: boolean;
transient: boolean; // gone after a reload
source: WorkspaceSource | null; // who opened it
active: boolean;
view: GraphView; // this named tab, even in the background
}
interface WorkspaceSource {
kind: string; // your own label, e.g. "agent-variant"
pluginId: string;
jobId?: string;
variantId?: string;
[key: string]: unknown; // opaque to the editor, handed back verbatim
}
Opening graphs
const opened = api.workspace.openGraphs(
candidates.map((c) => ({
title: `${hypothesis} — ${c.label}`,
graph: c.graph, // the shape getGraph() answers with
readOnly: true,
source: { kind: "agent-variant", pluginId: api.pluginId, variantId: c.id },
})),
{ activate: "first" },
);
for (const [i, result] of opened.entries()) {
if ("error" in result) {
api.ui.toast(`Candidate ${i + 1} not opened: ${result.error}`, "error");
continue;
}
remember(result.tabId, result.revision);
}
Each entry is validated in this order, and a failure produces a result with an error sentence and a code:
titlemust be a non-empty string —invalid_graph.graphmust surviveJSON.stringify—invalid_graph.- That JSON must be at most 8 MiB —
too_large. - Opening must not take the editor past 32 tabs —
too_many_tabs. - The graph must read through the editor's document reader, the same one opening a gallery example uses: a node's
paramsare taken exactly as the entry wrote them, and nothing is filled in from its definition or its preset — a param you leave out stays out; unknown top-level keys are ignored,subgraphs,segmentGroups,presets,settings.deviceandsettings.seedare honoured (a device or seed value the editor does not accept reads as unset, and a graph without a seed takes the seed of the tab that was active), and aformat_versionnewer than this editor opens the tab read-only exactly as a file would. A reader failure isinvalid_graph, with the reader's own message.
The tab count is checked against the live count, so two entries in one call cannot both slip under a limit only one of them fits. It is checked before the graph is read, because reading is what merges presets this server has never seen into the palette: an entry refused with too_many_tabs leaves the palette as it was.
options.activate is "first" (the default), "last", or "none" to leave the user where they are.
An opened tab is an ordinary tab afterwards. The user can rename it, close it, or Save As into a real graph file — and opening that file gives them an editable tab the usual way. readOnly belongs to the tab and lasts until the tab is closed.
Tabs you open are transient by default: they are not written to the editor's autosave, so they are gone after a reload. Pass persist: true if a candidate is meant to survive one. There is deliberately no closeTab: a tab the user is looking at is theirs to close.
Writing under a compare-and-swap
api.graph.getView() describes only the active tab. Since CodefyUI 2.8.7, with apiVersion still 5, every entry from workspace.tabs() and every successful workspace.snapshot(tabId) also carries the live view of that named tab, whether it is active or in the background. An older editor leaves the field out and can report only the active tab's level, through graph.getView(), so check for the field rather than the version:
const target = api.workspace.snapshot(tabId);
if ("error" in target) return;
const fallback = target.active ? api.graph.getView() : null; // before 2.8.7
const view = "view" in target ? target.view : fallback;
if (view && !view.atTopLevel) {
const inside = view.path[view.path.length - 1].name;
api.ui.toast(`Step out of "${inside}" before this write.`, "warning");
return;
}
This preflight makes editing_subgraph predictable; it is not a lock. The user can enter a block after the read, and on an older editor a background tab has no view to check, so callers must still handle that conflict from applyOperations. Entering or leaving a block swaps the tab's canvas, so it advances the tab's revision even when nothing inside the block is edited, and reaches workspace.onChanged as a graph event for that tab. A revision read before the user steps back out no longer matches: retry from a new workspace.snapshot(tabId) once its view is back at top level, with its revision as expectedRevision.
const before = api.workspace.snapshot(); // the active tab
if ("error" in before) return; // no tab is open
const armed = { tabId: before.tabId, revision: before.revision };
// ...minutes pass, experiments run...
const result = api.workspace.applyOperations({
tabId: armed.tabId,
expectedRevision: armed.revision,
operations: winner.operations,
atomic: true,
});
if (result.conflict === "revision_mismatch") {
// result.revision is the CURRENT one, so you can re-arm without re-reading.
api.ui.toast("The tab changed while the study was running.", "warning");
} else if (result.conflict === "read_only") {
api.ui.toast("That tab is read-only — promote into an editable one.", "warning");
} else if (result.conflict === "editing_subgraph") {
// Stepping back out advances the revision: retry from a new snapshot.
api.ui.toast("Step out of the block first — the write is waiting.", "warning");
} else if (result.conflict === "unknown_tab") {
api.ui.toast("That tab was closed while the study was running.", "warning");
} else if (!result.committed && result.results.some((r) => !r.ok)) {
const failed = result.results.filter((r) => !r.ok);
api.ui.toast(`Nothing applied: ${failed.map((r) => r.error).join("; ")}`, "error");
} else {
armed.revision = result.revision; // continue the chain
}
The checks run in this order, and each one returns without changing anything:
tabId(or the active tab) must resolve, elseconflict: "unknown_tab",results: [],committed: false,revision: 0.- The tab must not be read-only, else
conflict: "read_only",results: [],committed: false, and the tab's currentrevision. - The tab must not be showing the inside of a block, else
conflict: "editing_subgraph". While a block is open the canvas holds that block's contents rather than the documentsnapshot()describes, so a write would land somewhere you never read. Retry from a newworkspace.snapshot(tabId)once itsviewis back at top level;graph.getView()describes only the active tab. expectedRevision, if you passed one, must equal the tab'srevision, elseconflict: "revision_mismatch"and the current revision, so you can re-arm without a second read.- The batch is applied to a copy.
- With
atomic: true, if any op failed, nothing is written:committed: false,revisionunchanged, and the full-lengthresultsso you can see which op was wrong. - Otherwise, if anything changed: one undo snapshot, one write,
committed: true, and the newrevision. A batch that leaves the document exactly as it was commits nothing and adds no undo step, even when every op in it succeeded — amove_nodeto where the node already stands, for example — socommitted: falseon its own does not mean an op failed.
Whenever a call commits nothing — a refusal, a failed atomic preflight, a batch that changed nothing — node_count and edge_count describe the tab as it stands, so a plugin that logs them is never handed the counts of a graph that was thrown away. unknown_tab is the exception: there is no tab to count.
Conflicts are returned, never thrown. A batch is still at most one undo step, and per-op semantics are unchanged from api.graph.applyOperations: without atomic, a failing op is skipped and reported while the rest apply. A commit that leaves the graph without the node a detail modal or the canvas selection names clears both, so an undo restoring that node cannot pop the modal open by itself.
type WorkspaceConflict =
"revision_mismatch" | "read_only" | "unknown_tab" | "editing_subgraph";
interface WorkspaceApplyResult extends ApplyResult {
tabId: string;
revision: number; // AFTER this call; unchanged on a conflict or a preflight failure
committed: boolean;
conflict?: WorkspaceConflict;
}
Watching every tab
const off = api.workspace.onChanged((event) => {
if (event.type === "graph" && event.origin?.pluginId === api.pluginId) return; // your own write
if (event.type === "tabs" && event.removed) forget(event.tabId);
});
type WorkspaceEvent =
| { type: "graph"; tabId: string; revision: number; origin?: { pluginId: string } }
| { type: "tabs"; tabId: string; revision: number; removed: boolean }
| { type: "active-tab"; tabId: string; revision: number };
Events arrive synchronously after the change, in a fixed order: tabs added, then documents changed, then the active tab, then tabs removed. origin is present on a graph event only when the change came from a plugin write — either workspace.applyOperations or the legacy graph.applyOperations, both of which stamp it — which is how you tell your own writes from the user's.
If your callback throws, the editor logs it and unsubscribes you — a plugin must not be able to break the tab store. api.graph.onGraphChanged is unchanged: still the active tab, still no payload.
One change for plugins written before apiVersion 5
A read-only tab now refuses api.graph.applyOperations too. Every op comes back { ok: false, error: "tab is read-only" } and nothing is written. Before apiVersion 5 the write went through: readOnly was a UI affordance that the plugin write path did not consult. If your plugin writes to whatever tab is in front of the user, check api.workspace.snapshot().readOnly first, or read the ok flags you were already getting back.
One further refusal is new rather than changed, because both ops it names are new in apiVersion 5: while the user is inside a block, a legacy batch containing set_segment or remove_segment is refused whole-batch, every op reporting set_segment and remove_segment cannot apply while a block is open. Segments are top-level state, so committing one from in there would write an overlay naming the block's inner node ids — it reaches the saved file, draws nothing, and the user cannot delete what never renders. Every other op still writes the open canvas from inside a block, exactly as before.
When no tab is open
Since CodefyUI 2.8.4 the user can close the last tab, and the editor then shows its welcome screen. apiVersion is still 5. In that state:
graph.getGraph()returns an empty graph:{ nodes: [], edges: [], presets: [], segmentGroups: [], subgraphs: [] }.graph.getView()reports the top level.graph.applyOperations()writes nothing and returnsresults: [](not one entry per op),refs: {}, and counts of 0.workspace.tabs()returns[],workspace.snapshot()returns{ error: "unknown_tab" }, andworkspace.applyOperations()without atabIdreturnsconflict: "unknown_tab".- Closing the last tab fires
graph.onGraphChanged, andworkspace.onChangedreceives{ type: "active-tab", tabId: "", revision: 0 }followed by{ type: "tabs", removed: true }for the closed tab. workspace.openGraphs()still works. Withactivate: "none"the new tabs join the tab strip, but the welcome screen stays until the user opens one.- Dock and right-hand panels are detached from the page (
onHideruns). The element and what you mounted in it stay alive, and the panel is attached again once a tab is open. Toolbar buttons are hidden until a tab opens. Floating widgets stay on screen.
api.nodes — custom node renderers
Requires api.apiVersion >= 2.
| Method | Signature | Description |
|---|---|---|
registerRenderer | (nodeType, renderer) => () => void | Draw a plugin node type's card body with your own UI. Returns an unregister function. |
nodeType must match the node's namespaced type from getNodeDefinitions(): <plugin-id>:<NODE_NAME>. The plugin id is copied exactly from the manifest, including hyphens, so plugin my-plugin exposes my-plugin:MyNode. Only the Python import path converts hyphens to underscores (cdui_plugins.my_plugin). Registering my_plugin:MyNode therefore has no effect. When a registered type matches no loaded node definition, the editor logs a console warning, and names the correctly namespaced type when one exists. The renderer uses an imperative API and does not require the host or plugin to use a particular UI framework:
interface NodeRenderContext {
node: {
id: string;
type: string;
params: Record<string, unknown>;
definition?: unknown; // the node's NodeDefinition, as the editor holds it
};
}
interface PluginNodeRenderer {
mount(container: HTMLElement, ctx: NodeRenderContext): void;
update?(container: HTMLElement, ctx: NodeRenderContext): void; // after mount, then on each params or type change
unmount?(container: HTMLElement): void;
}
The editor still renders the standard node card (title, ports, param list) and hands your renderer a <div> for the body — slotted between the ports and the params. A node type with no registered renderer renders exactly like a default node.
api.nodes.registerRenderer('my-plugin:MyNode', {
mount(el, ctx) { el.textContent = `value: ${ctx.node.params.value}`; },
update(el, ctx) { el.textContent = `value: ${ctx.node.params.value}`; },
});
The plugin template's SDK wraps this with createRoot, so you can write the body as a React component.
api.events — live run events
Requires api.apiVersion >= 3.
| Method | Signature | Description |
|---|---|---|
onExecution | (cb: (event: ExecutionEvent) => void) => () => void | Subscribe to the run event stream. Returns an unsubscribe function. |
type ExecutionEvent =
| { type: "run_started"; run_id: string; cursor: number; seq: number }
| { type: "node_status"; run_id: string; cursor: number; seq: number;
node_id: string; status: string; error?: string }
| { type: "metric"; run_id: string; cursor: number; seq: number;
points: readonly RunMetricPoint[] }
| { type: "run_finished"; run_id: string; cursor: number; seq: number;
status: "succeeded" | "failed" | "cancelled" | "interrupted";
error?: string };
const off = api.events.onExecution((event) => {
if (event.type === "metric") {
for (const p of event.points) record(p.name, p.step, p.value);
}
if (event.type === "run_finished") summarise(event.run_id, event.status);
});
Things worth knowing before you build on it:
- A
metricevent carries the whole batch it was recorded as, inpoints. Those areRunMetricPoints — the same typeapi.runs.metrics()returns — so one fold function can serve the live tail and the REST back-fill. In particularvalueisnullfor a non-finite number on both sides: a diverged loss is a gap in the curve, not a zero, and it is delivered rather than skipped. (The one difference:tsis populated on points fromapi.runs.metrics()and absent on live ones.) - Events are frozen. One event object is shared by every subscriber, so it and its
pointsareObject.freezed — you cannot mutate what another plugin receives, and you should copy before transforming. - Events are batched onto animation frames. A run pushing hundreds of metrics a second reaches you as one burst of calls per frame, not one call per message — the same batching the editor uses for its own node badges. A backgrounded or occluded editor window is never painted, so nothing is delivered until it comes back; what piles up in the meantime is bounded by what it costs to keep — an event plus each metric point it carries, capped at about twenty thousand of those — and past the cap the oldest metrics and node statuses are dropped (
run_startedandrun_finishednever are). The unit matters because onemetricevent carries a whole batch: a run writing fat batches and one writing single points get the same memory budget, not the same number of events. Re-read metrics fromapi.runs.metrics()if you need every point. - It is a tail, not a transcript. The editor replays a run's whole recorded log whenever it attaches to one — on a page reload with a run in flight, or when the user picks a run to watch in the Runs panel. Those replayed entries pass through the same stream, and the host filters out every one already delivered, so a re-attach can never hand you a duplicate to double-count. The exceptions are spelled out under when the editor attaches to a run you have not seen.
- Unsubscribe when you are done; it takes effect immediately, including part-way through a batch. The editor also unsubscribes you automatically on unload or hot-reload.
- The stream covers the runs the editor is attached to — the ones started from a canvas tab, plus any run the user chose to watch from the Runs panel. A run submitted by
cdui runthat nobody is watching is visible throughapi.runs, not here. - If your callback throws, the editor logs it and moves on. No other subscriber is affected, but you lose that event.
cursor and seq
Every event carries two numbers, and mixing them up is the easiest way to build a dashboard that lies to its user.
cursor is where the event sits in the run's durable log — the same cursor GET /api/runs/{id}/events pages by and api.runs.get(id).last_cursor reports. Use it to line an event up against the REST side.
It is strictly increasing within a run, but it is not dense, and a jump in it means nothing. The log also holds entries this stream does not publish, and each one consumes a cursor:
artifact— every checkpoint a run saves writes one;run_warning;- a refused submit, and a cancel that had nothing to cancel;
- a metric entry the server collapsed because its payload was too large.
A perfectly healthy training run that checkpoints every epoch therefore produces a cursor gap every epoch. Do not treat that as data loss.
seq is the stream's own counter, and it is the one that signals loss. It counts the events delivered for a run, densely: the next event you receive for a run has seq exactly one higher than the last one you received — unless the host dropped events under the buffering limit described above, which is the only thing that can put a hole in it.
// Per run: remember the last seq you saw, and react to a hole.
const lastSeq = new Map();
api.events.onExecution((event) => {
const previous = lastSeq.get(event.run_id);
lastSeq.set(event.run_id, event.seq);
if (previous !== undefined && event.seq > previous + 1) {
// The only cause is buffer overflow. Recover from REST.
void api.runs.metrics(event.run_id).then(backfill);
}
apply(event);
});
The first seq you see for a run is your baseline, not necessarily 1: it counts from when the editor started streaming that run, which may be before your plugin subscribed.
When the editor attaches to a run you have not seen
The de-duplication above is bookkeeping the editor keeps per run, shared by all plugins — not per plugin — for as long as at least one plugin is subscribed. Two consequences:
- When the editor attaches to a run nothing has streamed yet — the user clicking a run in the Runs panel — the server replays that run's recorded log from the start, and you receive it, in cursor order, before the live tail begins. Every entry still arrives exactly once, but the first events you see for that run describe the past.
- When the editor attaches to a run something has already streamed, the replay is filtered out for everyone. If your plugin subscribed later than another one, you inherit that filtering, so you may see nothing at all from the replay of a run you personally never saw. Do not rely on a replay to populate yourself; use
api.runsfor that, which is what it is for.
If you need to know whether an event describes the past, api.runs.get(run_id) reports last_cursor. Note it is not a one-liner for a run that is still going: you are reading a moving target after the replay has already started, so the honest pattern is to buffer events until the promise resolves and only then classify them.
The bookkeeping has two limits. The editor remembers the last 1024 runs it has streamed, so attaching to more than 1024 distinct runs and then returning to one of the earliest replays it to you a second time. And the whole table, with any buffered events not yet delivered, is dropped when the last subscription ends, which happens in the teardown before every re-activation (see The activate contract). After a re-activation, seq starts again at 1 for a run already in flight, and the next attach to a run replays its log again.
api.runs — run history (read-only)
Requires api.apiVersion >= 3.
| Method | Signature | Description |
|---|---|---|
list | (opts?) => Promise<RunListPage> | Newest-first page of runs. opts is { status?, limit?, offset? }. |
get | (id: string) => Promise<RunInfo | null> | One run, or null when the server has never heard of it. |
metrics | (id: string, name?: string) => Promise<RunMetrics> | Recorded scalar series, ordered (name, step). |
const page = await api.runs.list({ status: ["running"], limit: 1 });
const active = page.runs[0];
if (active) {
const recorded = await api.runs.metrics(active.id);
for (const point of recorded.metrics) {
if (point.value !== null) record(point.name, point.step, point.value);
}
}
interface RunListPage { runs: RunSummary[]; total: number; limit: number; offset: number }
interface RunInfo extends RunSummary { last_cursor: number }
interface RunMetrics { run_id: string; names: string[]; metrics: RunMetricPoint[] }
interface RunMetricPoint {
node_id: string | null; name: string; step: number;
value: number | null; // null is a diverged (non-finite) value — a gap, not a zero
ts?: string; // ISO-8601 UTC; set here, absent on live metric events
}
RunMetricPoint is the same type the live metric event carries in points, so a dashboard can fold both with one function. ts is the only field that differs between the two sources: api.runs.metrics() records when each point was written, the live stream carries only what a chart plots against step. A fold that ignores ts works on both unchanged.
RunSummary mirrors a row of the run history: id, name, status, error, options, queue_key, created_at, started_at, finished_at, git_commit, git_dirty, plugin_pins, queue_position, final_metrics and active. The full shapes are in the vendored SDK types, and the endpoints behind them are documented in the API Reference.
The facade exists so the common case needs no hand-rolled fetching: the editor performs the requests through its own API client, with whatever authentication they need already attached. You never construct a URL, and the token is never passed to your code or returned by anything on api.runs — that is a convenience, not a sandbox (see Trust model).
It is deliberately read-only in this version. There is no submit and no cancel: starting or stopping work on someone's machine should happen behind a UI they opened, not behind a plugin call. If you need that, drive it from a button the user pressed, through api.http.fetch.
api.http — session-aware fetch
| Method | Signature | Description |
|---|---|---|
fetch | (path: string, init?: RequestInit) => Promise<Response> | The browser fetch with the same arguments. On POST, PUT, PATCH and DELETE it adds the session token header X-CodefyUI-Token (GET, HEAD and OPTIONS go out unchanged); after a 403 it reads the token again and retries once if the server has restarted since. The token goes only to the editor's own origin: a POST, PUT, PATCH or DELETE to any other origin is refused (the promise rejects with a TypeError and nothing is sent), so call window.fetch for other servers. Use this for all calls to the CodefyUI backend, addressed by a path such as /api/llm/chat. |
api.storage — namespaced key-value store
Storage is backed by localStorage and automatically namespaced to your plugin id, so different plugins cannot collide.
| Method | Signature | Description |
|---|---|---|
get | (key: string) => string | null | Retrieve a stored value. |
set | (key: string, value: string) => void | Store a value. |
remove | (key: string) => void | Delete a key. |
Trust model
Plugin JavaScript runs inside the editor page with full access to the editor DOM, graph state, and session token. Only install plugins from sources you trust. The cdui plugin install CLI prints a warning whenever a plugin declares a frontend entry point.
The backend AST security gate applies to plugin Python; there is no sandbox for plugin JavaScript — it runs with the same trust level as the editor itself.
Minimal working example
The snippet below uses only the raw API — no build step, no framework: a single button that inserts two nodes and wires them together. (For a real React-based panel, see the Graph Copilot plugin source.)
// frontend/index.js
export default function activate(api) {
const btn = document.createElement("button");
btn.textContent = "Insert Linear + Activation";
btn.style.cssText =
"padding:6px 12px;background:#0d9488;color:#fff;border:none;border-radius:4px;cursor:pointer";
btn.addEventListener("click", () => {
// applyOperations is synchronous — no await.
const result = api.graph.applyOperations([
{ op: "add_node", node_type: "Linear", ref: "lin1", position: { x: 200, y: 200 } },
{ op: "add_node", node_type: "Activation", ref: "act1",
params: { function: "relu" }, position: { x: 440, y: 200 } },
// Handle names ("tensor" here) come from each node's port schema —
// call api.graph.getNodeDefinitions() to discover them.
{ op: "connect",
source: "lin1", source_handle: "tensor",
target: "act1", target_handle: "tensor" },
]);
const failed = result.results.filter((r) => !r.ok);
if (failed.length > 0) {
api.ui.toast(`Some ops failed: ${failed.map((r) => r.error).join(", ")}`, "warning");
}
});
// addFloatingWidget returns a container <div> you fill yourself.
const panel = api.ui.addFloatingWidget({ id: "demo-insert-panel" });
panel.appendChild(btn);
}
A live run-metrics panel
The example below is the apiVersion 3 surface doing the thing it was added for: a dock tab that lists a run's metrics as they arrive. It pairs the two halves — events.onExecution for the live tail, runs for everything that happened before the panel opened — and subscribes before back-filling so nothing falls through the gap between them.
// frontend/index.js
export default function activate(api) {
if (api.apiVersion < 3) return;
const series = new Map(); // name -> { last, points }
// One fold for both halves: `event.points` and `runs.metrics().metrics`
// are the same RunMetricPoint[].
const fold = (points) => {
for (const p of points) {
const previous = series.get(p.name);
series.set(p.name, {
// null is a diverged value — a gap, so keep the last finite one.
last: p.value ?? previous?.last ?? null,
points: (previous?.points ?? 0) + 1,
});
}
};
const render = () => {
if (!el.isConnected) return; // the tab is not open; nothing to paint
el.textContent = [...series.entries()]
.map(([name, s]) =>
`${name} last=${s.last === null ? "--" : s.last.toFixed(4)} n=${s.points}`)
.join("\n");
};
const el = api.ui.addPanel({
id: "run-metrics", title: "Run Metrics", icon: "~",
onShow: render, // paint on the way in, so the tab is never blank
});
// 1. the live tail
api.events.onExecution((event) => {
if (event.type === "run_started") series.clear();
if (event.type === "metric") fold(event.points);
render();
});
// 2. the back-fill, for a run that started before this panel existed
api.runs.list({ status: ["running"], limit: 1 }).then(async (page) => {
const active = page.runs[0];
if (!active) return;
const recorded = await api.runs.metrics(active.id);
// Same fold, filtered to the series the live tail has not covered.
fold(recorded.metrics.filter((p) => !series.has(p.name)));
render();
});
}
The plugin scaffold ships the same example as a React component at ui/src/examples/run-metrics-panel.tsx, using the SDK's mountPanel, useExecutionEvents and useRuns bindings.
See also
- Plugins — installing packs, the manifest format, and the
cdui pluginCLI. - Graph Copilot — the first production consumer of the frontend extension API.
- API Reference — backend REST endpoints, including
/api/llm/chat.