This guide explains how to create custom workflow nodes for SmartBotic.
Nodes are JavaScript modules located in nodes/<category>/ directories. Each node file must export:
configSchema - JSON Schema for node configurationinputSchema - JSON Schema for input dataoutputSchema - JSON Schema for output dataexecute - Async function that performs the node's action/**
* @node my-node-id
* @name My Node Name
* @category my-category
* @version 1.0.0
* @description What this node does
* @icon icon-name
*/
const configSchema = {
type: 'object',
properties: {
myOption: {
type: 'string',
title: 'My Option',
description: 'Description of this option',
default: 'default-value'
}
},
required: ['myOption']
};
const inputSchema = {
type: 'object',
properties: {
data: {
type: 'any',
description: 'Input data'
}
}
};
const outputSchema = {
type: 'object',
properties: {
result: {
type: 'string',
description: 'Output result'
}
}
};
module.exports = {
configSchema,
inputSchema,
outputSchema,
async execute(config, input, context) {
// Your node logic here
return {
result: 'success'
};
}
};
The comment block at the top of the file defines node metadata:
| Tag | Required | Description |
|---|---|---|
@node |
Yes | Unique node identifier (kebab-case) |
@name |
Yes | Display name shown in UI |
@category |
Yes | Category for grouping (e.g., http, data, email) |
@version |
Yes | Semantic version (e.g., 1.0.0) |
@description |
Yes | Brief description of node functionality |
@icon |
No | Lucide icon name (e.g., globe, mail, database) |
@trigger |
No | Add this tag if the node is a trigger (starts workflows) |
The configSchema defines what options users can configure in the node editor.
// String field
myString: {
type: 'string',
title: 'Label',
description: 'Help text',
default: 'default value'
}
// Number field
myNumber: {
type: 'number',
title: 'Count',
default: 10
}
// Boolean field
myBoolean: {
type: 'boolean',
title: 'Enable Feature',
default: false
}
// Dropdown (enum)
myChoice: {
type: 'string',
title: 'Select Option',
enum: ['option1', 'option2', 'option3'],
default: 'option1'
}
// Object field
myHeaders: {
type: 'object',
title: 'Headers',
additionalProperties: { type: 'string' }
}
To create a credential selector:
credentialId: {
type: 'string',
title: 'Credential',
description: 'Select authentication credential',
dynamicOptions: {
source: 'credentials',
filter: { type: 'bearer' } // Filter by credential type
}
}
Available credential types: bearer, api_key, basic, imap
Define multiple output ports:
const outputs = [
{ name: 'success', displayName: 'Success', type: 'object', color: '#10b981' },
{ name: 'error', displayName: 'Error', type: 'object', color: '#ef4444' }
];
module.exports = {
configSchema,
inputSchema,
outputSchema,
outputs,
execute
};
Inside execute(), you have access to the smartbotic global object:
smartbotic.log.debug('Debug message');
smartbotic.log.info('Info message');
smartbotic.log.warn('Warning message');
smartbotic.log.error('Error message');
const response = smartbotic.http.request({
method: 'GET', // GET, POST, PUT, PATCH, DELETE
url: 'https://api.example.com/data',
headers: { 'Authorization': 'Bearer token' },
body: JSON.stringify({ key: 'value' }),
timeout: 30000,
followRedirects: true
});
// Response: { status, headers, data }
// Insert document
const result = smartbotic.storage.insert('collection', { key: 'value' });
// Returns: { success, id }
// Get document
const doc = smartbotic.storage.get('collection', 'document-id');
// Returns: { found, document }
// Query documents
const results = smartbotic.storage.query('collection', { field: 'value' });
// Returns: { success, documents }
// Update document
smartbotic.storage.update('collection', 'document-id', { key: 'new-value' });
// Delete document
smartbotic.storage.delete('collection', 'document-id');
const auth = smartbotic.credentials.get(credentialId);
if (auth.success) {
// auth.headerName = 'Authorization' or 'X-API-Key'
// auth.headerValue = 'Bearer <token>' or '<api-key>'
headers[auth.headerName] = auth.headerValue;
}
// Generate UUID
const id = smartbotic.utils.uuid();
// Sleep (pause execution)
smartbotic.utils.sleep(1000); // milliseconds, max 300000 (5 min)
// Template interpolation
const result = smartbotic.utils.interpolate('Hello {{name}}', { name: 'World' });
// Base64 encoding/decoding
const encoded = smartbotic.utils.base64Encode('data');
const decoded = smartbotic.utils.base64Decode(encoded);
// SHA256 hash
const hash = smartbotic.utils.sha256('data'); // Returns hex string
// Object utilities
const picked = smartbotic.utils.pick(obj, ['key1', 'key2']);
const omitted = smartbotic.utils.omit(obj, ['unwantedKey']);
Use 4 spaces for indentation (consistent with existing nodes).
Do NOT use // comments inside schema definitions. The parser may incorrectly interpret them:
// BAD - comment may break parsing
const configSchema = {
type: 'object',
properties: {
url: {
type: 'string',
default: 'http://localhost:3000' // This is the default <- AVOID
}
}
};
// GOOD - no inline comments in schema
const configSchema = {
type: 'object',
properties: {
url: {
type: 'string',
default: 'http://localhost:3000'
}
}
};
Use single quotes for string values in schemas. The parser converts them to JSON:
// GOOD
title: 'My Title'
// AVOID - embedded quotes can cause issues
description: 'Use "quotes" carefully' // May break parsing
IMPORTANT: Throw errors instead of returning success: false. This ensures the workflow fails properly:
async execute(config, input, context) {
// Validate required inputs
if (!config.requiredField) {
throw new Error('Required field is missing');
}
// Make API call
const response = smartbotic.http.request({
method: 'GET',
url: config.url,
headers: headers
});
// Check response status - throw on error
if (response.status < 200 || response.status >= 300) {
const errorMsg = typeof response.data === 'string'
? response.data
: JSON.stringify(response.data);
throw new Error(`API error (${response.status}): ${errorMsg}`);
}
// Return data on success (no try/catch needed for most cases)
return {
data: response.data
};
}
Do NOT catch errors just to return { success: false } - let them propagate so the workflow fails.
After creating or modifying node files, migrate them to the running database:
# Get authentication token
TOKEN=$(curl -s http://localhost:8090/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "admin"}' | jq -r '.accessToken')
# Migrate nodes
curl -X POST http://localhost:8090/api/v1/nodes/migrate \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"nodesPath": "./nodes"}'
Runners automatically receive node updates via gRPC streaming.
/**
* @node download-file
* @name Download File
* @category http
* @version 1.0.0
* @description Download a file from URL
* @icon download
*/
const configSchema = {
type: 'object',
properties: {
url: {
type: 'string',
title: 'URL',
description: 'URL to download from'
},
timeout: {
type: 'number',
title: 'Timeout (ms)',
default: 30000
}
},
required: ['url']
};
const inputSchema = {
type: 'object',
properties: {
url: { type: 'string', description: 'Override URL from input' }
}
};
const outputSchema = {
type: 'object',
properties: {
success: { type: 'boolean' },
data: { type: 'string' },
contentType: { type: 'string' }
}
};
module.exports = {
configSchema,
inputSchema,
outputSchema,
async execute(config, input, context) {
const url = input.url || config.url;
smartbotic.log.info(`Downloading from ${url}`);
try {
const response = smartbotic.http.request({
method: 'GET',
url: url,
timeout: config.timeout
});
if (response.status < 200 || response.status >= 300) {
throw new Error(`HTTP ${response.status}`);
}
return {
success: true,
data: response.data,
contentType: response.headers['content-type'] || ''
};
} catch (error) {
smartbotic.log.error(`Download failed: ${error.message}`);
return {
success: false,
error: error.message
};
}
}
};
Trigger nodes start workflow executions. Add @trigger to the JSDoc:
/**
* @node my-trigger
* @name My Trigger
* @category triggers
* @version 1.0.0
* @description Triggers workflow on event
* @icon zap
* @trigger
*/
Triggers typically don't have input schemas (they generate initial data).