How to Create Custom Node Types with Plugins¶
This guide shows you how to extend Canvas-Chat with custom node types using the plugin system.
For complete plugin development including feature plugins and backend handlers, see How to Create Plugins.
Overview¶
Canvas-Chat's plugin system allows you to create custom node types with:
- Custom rendering (HTML/CSS)
- Custom actions (buttons in the node toolbar)
- Custom event handlers (click, hover, etc.)
- Custom edit modals (single or multiple fields)
- Automatic registration and loading
Plugins are JavaScript ES modules that import from Canvas-Chat's core APIs.
Prerequisites¶
- Canvas-Chat with a
config.yamlfile - Basic knowledge of JavaScript and HTML/CSS
Note: Plugins work with or without --admin-mode. Use whichever mode fits your deployment.
Step 1: Create a Plugin File¶
Create a new JavaScript file for your plugin (e.g., my-custom-node.js):
/**
* Custom Node Plugin Example
*/
import { BaseNode, Actions } from '/static/js/node-protocols.js';
import { NodeRegistry } from '/static/js/node-registry.js';
class MyCustomNode extends BaseNode {
/**
* Display label shown in node header
*/
getTypeLabel() {
return 'My Custom Node';
}
/**
* Emoji icon for the node type
*/
getTypeIcon() {
return '🎯';
}
/**
* Render the HTML content for the node
*/
renderContent(canvas) {
const content = this.node.content || 'No content';
return `
<div class="my-custom-content">
<p>${canvas.escapeHtml(content)}</p>
<button class="my-button">Click Me</button>
</div>
`;
}
/**
* Action buttons for the toolbar
*/
getActions() {
return [{ id: 'custom-action', label: '✨ Custom', title: 'Do something custom' }, Actions.REPLY, Actions.COPY];
}
/**
* Event bindings for interactive elements
*/
getEventBindings() {
return [
{
selector: '.my-button',
handler: (nodeId, e, canvas) => {
alert('Button clicked!');
// Emit custom events to the canvas
canvas.emit('myCustomEvent', nodeId);
},
},
];
}
}
// Register the node type
NodeRegistry.register({
type: 'my-custom',
protocol: MyCustomNode,
defaultSize: { width: 400, height: 300 },
css: `
.my-custom-content {
padding: 16px;
}
.my-button {
padding: 8px 16px;
background: var(--primary-color);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.my-button:hover {
opacity: 0.8;
}
`,
});
console.log('My custom node plugin loaded');
Step 2: Add Plugin to Config¶
Edit your config.yaml to include the plugin:
models:
- id: 'openai/gpt-4o'
name: 'GPT-4o'
apiKeyEnvVar: 'OPENAI_API_KEY'
plugins:
# Format 1: Simple path (JavaScript-only)
- path: ./plugins/my-custom-node.js
# OR
- js: ./plugins/my-custom-node.js
# Format 2: Paired plugin (JavaScript + Python)
# - id: my-plugin
# js: ./plugins/my-custom-node.js
# py: ./plugins/my_handler.py
# Format 3: Absolute path
# - path: /Users/you/projects/my-custom-node.js
Note: For complete plugin architecture including feature plugins and backend handlers, see How to Create Plugins.
Step 3: Run Canvas-Chat¶
Start canvas-chat with your config file:
# Normal mode (users provide their own API keys)
uvx canvas-chat launch --config config.yaml
# OR admin mode (server-side keys)
uvx canvas-chat launch --config config.yaml --admin-mode
You should see in the logs:
Step 4: Create Nodes of Your Type¶
In the browser console or via the API, create nodes:
// In browser console
const node = createNode('my-custom', 'Hello from my custom node!');
app.graph.addNode(node);
app.canvas.renderNode(node);
Node Protocol API Reference¶
Core Methods (Required)¶
Every custom node class must extend BaseNode and implement these core methods:
getTypeLabel(): string¶
Display name shown in the node header.
getTypeIcon(): string¶
Emoji or text icon shown in the node header.
renderContent(canvas): string¶
Returns HTML for the node's content area.
renderContent(canvas) {
// Access node data
const text = this.node.content;
// Use canvas utilities
const escaped = canvas.escapeHtml(text);
const truncated = canvas.truncate(text, 100);
return `<div class="content">${escaped}</div>`;
}
Display Methods (Optional)¶
getSummaryText(canvas): string¶
Text shown when zoomed out. Defaults to truncated content.
getActions(): Array<Action>¶
Action buttons in the toolbar.
getActions() {
return [
{ id: 'custom', label: '✨ Custom', title: 'Custom action' },
Actions.REPLY,
Actions.COPY,
];
}
getHeaderButtons(): Array<HeaderButton>¶
Buttons in the node header (next to delete).
getHeaderButtons() {
const base = super.getHeaderButtons(); // Gets standard buttons
return [
...base,
{
id: 'custom-header',
icon: '⚙️',
title: 'Settings',
class: 'custom-header-btn',
},
];
}
getEventBindings(): Array<EventBinding>¶
Declarative event handlers for interactive elements.
getEventBindings() {
return [
// Single element
{
selector: '.my-button',
handler: (nodeId, e, canvas) => {
console.log('Button clicked', nodeId);
},
},
// Multiple elements
{
selector: '.list-item',
multiple: true, // Handle all matching elements
handler: (nodeId, e, canvas) => {
const index = e.currentTarget.dataset.index;
console.log('Item clicked', index);
},
},
// Named event (emitted by canvas)
{
selector: '.vote-btn',
handler: 'pollVote', // String = event name
},
];
}
isScrollable(): boolean¶
Whether the node content should scroll. Defaults to true.
getEditFields(): Array<EditField>¶
Define custom edit fields for the edit content modal. Override this to provide multiple fields (e.g., question and answer for flashcards).
Default behavior: Returns a single content field.
EditField structure:
{
id: string, // Unique field identifier (e.g., 'content', 'back', 'title')
label: string, // Display label shown above the textarea
value: string, // Initial value from node data
placeholder: string // Placeholder text for the textarea
}
Example - Single field (default):
getEditFields() {
return [
{
id: 'content',
label: 'Markdown',
value: this.node.content || '',
placeholder: 'Edit the fetched content...',
},
];
}
Example - Multiple fields (flashcard pattern):
getEditFields() {
return [
{
id: 'content',
label: 'Question',
value: this.node.content || '',
placeholder: 'Edit the question...',
},
{
id: 'back',
label: 'Answer',
value: this.node.back || '',
placeholder: 'Edit the answer...',
},
];
}
handleEditSave(fields, app): Object¶
Handle saving edited fields. Override this to customize save behavior (e.g., save multiple fields).
Parameters:
fields: Object mapping field IDs to values (e.g.,{ content: '...', back: '...' })app: App instance for graph updates
Returns: Object to pass to graph.updateNode() (e.g., { content: '...', back: '...' })
Default behavior: Saves the content field.
Example - Single field (default):
Example - Multiple fields (flashcard pattern):
renderEditPreview(fields, canvas): string¶
Render preview HTML for the edit modal. Override this to show custom preview (e.g., flashcard format with question and answer).
Parameters:
fields: Object mapping field IDs to valuescanvas: Canvas instance for helper methods
Returns: HTML string for preview
Default behavior: Renders markdown preview of the content field.
Example - Markdown preview (default):
renderEditPreview(fields, canvas) {
const content = fields.content || '';
return canvas.renderMarkdown(content);
}
Example - Custom preview (flashcard pattern):
renderEditPreview(fields, canvas) {
const front = canvas.escapeHtml(fields.content || 'No question');
const back = canvas.escapeHtml(fields.back || 'No answer');
return `
<div class="flashcard-container">
<div class="flashcard-status new">New</div>
<div class="flashcard-card">
<div class="flashcard-front">
<div class="flashcard-label">Question</div>
<div class="flashcard-text">${front}</div>
</div>
<div class="flashcard-back">
<div class="flashcard-label">Answer</div>
<div class="flashcard-text">${back}</div>
</div>
</div>
</div>
`;
}
getEditModalTitle(): string¶
Get modal title for edit dialog. Override this to customize the title.
Returns: Modal title string
Default behavior: Returns "Edit Content".
Example:
Other Optional Methods¶
copyToClipboard(canvas, app): Promise<void>¶
Custom copy behavior. Default copies text content.
async copyToClipboard(canvas, app) {
// Custom copy logic (e.g., format as markdown table, copy image)
const text = this.formatForCopy();
await navigator.clipboard.writeText(text);
canvas.showCopyFeedback(this.node.id);
}
supportsStopContinue(): boolean¶
Whether this node type supports stop/continue buttons for streaming. Default returns false.
getContentClasses(): string¶
Additional CSS classes for the node-content wrapper. Default returns empty string.
NodeRegistry.register() Options¶
NodeRegistry.register({
// Required
type: 'my-node', // Unique type identifier
protocol: MyNodeClass, // Class extending BaseNode
// Optional
defaultSize: {
// Default dimensions
width: 400,
height: 300,
},
css: `...`, // CSS rules for this node type
cssVariables: {
// CSS custom properties
'--node-my-node-bg': '#f0f0f0',
},
});
Available Utilities¶
Canvas Utilities (passed to renderContent)¶
canvas.escapeHtml(text); // Escape HTML entities
canvas.truncate(text, maxLength); // Truncate with ellipsis
canvas.emit(eventName, ...args); // Emit custom events
canvas.showCopyFeedback(nodeId); // Show "Copied!" feedback
Built-in Actions¶
import { Actions } from '/static/js/node-protocols.js';
Actions.REPLY; // Reply to node
Actions.COPY; // Copy content
Actions.SUMMARIZE; // Generate summary
Actions.EDIT_CONTENT; // Edit in modal (triggers getEditFields())
Actions.FETCH_SUMMARIZE; // Fetch and summarize URL
Actions.EDIT_CODE; // Edit code in modal
Actions.GENERATE; // Generate code
Actions.RUN_CODE; // Run Python code
Actions.ANALYZE; // Analyze CSV data
Actions.FLIP_CARD; // Flip flashcard
Actions.REVIEW_CARD; // Review flashcard
Actions.CREATE_FLASHCARDS; // Generate flashcards
Note: When Actions.EDIT_CONTENT is included in getActions(), clicking the edit button will:
- Call
getEditFields()to get field definitions - Show edit modal with fields dynamically rendered
- Call
renderEditPreview()for live preview - Call
handleEditSave()when saving - Use
getEditModalTitle()for the modal title
Best Practices¶
- Use ES module imports: Always import from
/static/js/paths - Escape user content: Use
canvas.escapeHtml()for any user-provided text - Handle errors gracefully: Wrap async operations in try-catch
- Keep nodes responsive: Avoid blocking the main thread
- Test in different zoom levels: Nodes should work at all zoom scales
- Use CSS variables: Follow Canvas-Chat's theme system
- Log plugin loading: Add
console.log()at the end for debugging
Complete Plugin Patterns¶
Pattern 1: Simple Node with Custom Rendering¶
See /static/js/example-plugins/example-poll-node.js for a complete example with:
- Custom rendering
- Interactive voting
- Event bindings
- CSS styling
- Copy to clipboard
Pattern 2: Node with Custom Edit Fields (Flashcard Pattern)¶
The flashcard node demonstrates the pattern for nodes that need multiple edit fields:
File: /static/js/flashcard-node.js
Key features:
- Multiple edit fields (question and answer)
- Custom preview rendering
- Custom save behavior
- Custom modal title
Complete example:
import { BaseNode, Actions } from './node-protocols.js';
import { NodeRegistry } from './node-registry.js';
class FlashcardNode extends BaseNode {
getTypeLabel() {
return 'Flashcard';
}
getTypeIcon() {
return '🎴';
}
getSummaryText(canvas) {
if (this.node.title) return this.node.title;
const plainText = (this.node.content || '').replace(/[#*_`>\[\]()!]/g, '').trim();
return canvas.truncate(plainText, 60);
}
renderContent(canvas) {
const front = canvas.escapeHtml(this.node.content || 'No question');
const back = canvas.escapeHtml(this.node.back || 'No answer');
// SRS status logic...
let statusClass = 'new';
let statusText = 'New';
// ... (status calculation)
return `
<div class="flashcard-container">
<div class="flashcard-status ${statusClass}">${statusText}</div>
<div class="flashcard-card">
<div class="flashcard-front">
<div class="flashcard-label">Question</div>
<div class="flashcard-text">${front}</div>
</div>
<div class="flashcard-back">
<div class="flashcard-label">Answer</div>
<div class="flashcard-text">${back}</div>
</div>
</div>
</div>
`;
}
getActions() {
return [Actions.FLIP_CARD, Actions.REVIEW_CARD, Actions.EDIT_CONTENT, Actions.COPY];
}
// Custom edit fields
getEditFields() {
return [
{
id: 'content',
label: 'Question',
value: this.node.content || '',
placeholder: 'Edit the question...',
},
{
id: 'back',
label: 'Answer',
value: this.node.back || '',
placeholder: 'Edit the answer...',
},
];
}
// Custom save behavior
handleEditSave(fields, app) {
return {
content: fields.content || '',
back: fields.back || '',
};
}
// Custom preview
renderEditPreview(fields, canvas) {
const front = canvas.escapeHtml(fields.content || 'No question');
const back = canvas.escapeHtml(fields.back || 'No answer');
return `
<div class="flashcard-container">
<div class="flashcard-status new">New</div>
<div class="flashcard-card">
<div class="flashcard-front">
<div class="flashcard-label">Question</div>
<div class="flashcard-text">${front}</div>
</div>
<div class="flashcard-back">
<div class="flashcard-label">Answer</div>
<div class="flashcard-text">${back}</div>
</div>
</div>
</div>
`;
}
// Custom modal title
getEditModalTitle() {
return 'Edit Flashcard';
}
}
NodeRegistry.register({
type: 'flashcard',
protocol: FlashcardNode,
defaultSize: { width: 400, height: 280 },
});
export { FlashcardNode };
console.log('Flashcard node plugin loaded');
When to use this pattern:
- Node has multiple editable fields (e.g., question/answer, front/back, title/description)
- Node needs custom preview format in edit modal
- Node needs custom save logic beyond simple content update
Troubleshooting¶
Plugin not loading¶
Check the server logs for:
- "Plugin file not found" - Check the path in config.yaml
- "Invalid plugin entry" - Check YAML syntax
- Browser console for import errors
Events not firing¶
- Ensure
getEventBindings()returns the correct selector - Check that the element exists in
renderContent()output - Use browser DevTools to inspect the DOM
Styling issues¶
- Use browser DevTools to inspect CSS cascade
- Check that CSS variables are defined
- Ensure CSS is scoped to your node type (
.node.my-node {})
Next Steps¶
- Read the Node Protocol Reference
- See example-poll-node.js for a complete implementation
- Learn about Admin Mode Configuration