# Creating Workflow Nodes This guide explains how to create custom workflow nodes for SmartBotic. ## Node File Structure Nodes are JavaScript modules located in `nodes//` directories. Each node file must export: - `configSchema` - JSON Schema for node configuration - `inputSchema` - JSON Schema for input data - `outputSchema` - JSON Schema for output data - `execute` - Async function that performs the node's action ## Basic Template ```javascript /** * @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' }; } }; ``` ## JSDoc Metadata Tags 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) | ## Configuration Schema The `configSchema` defines what options users can configure in the node editor. ### Supported Field Types ```javascript // 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' } } ``` ### Dynamic Options (Credentials) To create a credential selector: ```javascript 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` ## Custom Outputs Define multiple output ports: ```javascript 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 }; ``` ## Outputs That Come From Config A node whose output count depends on how it is configured declares `dynamicOutputs` beside its static `outputs`: ```javascript const outputs = [ { name: 'fallback', displayName: 'Fallback', type: 'any', color: '#6b7280' } ]; const dynamicOutputs = { from: 'rules', namePrefix: 'case', labelFrom: 'label', color: '#3b82f6' }; module.exports = { configSchema, inputSchema, outputSchema, outputs, dynamicOutputs, execute }; ``` For each entry in the placed node's `config.rules`, the editor draws a port named `case0`, `case1` and so on, labelled from that entry's `label` field, followed by the static outputs. Port names are positional, so an edge survives editing a rule's label or value. Deleting a rule drops the edges hanging off the port that went with it. At run time the node routes with `_activeBranch`, exactly as a fixed-port node does - the branch name is just computed: ```javascript return { _activeBranch: 'case' + i, ['case' + i]: data }; ``` ## Multiple Inputs A node that joins two branches names its inputs: ```javascript const inputs = [ { name: 'input1', displayName: 'Input 1', type: 'any', required: false }, { name: 'input2', displayName: 'Input 2', type: 'any', required: false } ]; module.exports = { configSchema, inputSchema, outputSchema, inputs, execute }; ``` Each named input becomes its own target handle, and arrives in `execute` under that name - `input.input1`, `input.input2`. A node that declares no `inputs` keeps the single `data` handle it has always had. Note the difference between a node that sets `_activeBranch` and one that does not: with the marker, exactly one output carries data and everything downstream of the others is skipped. Without it, every named output the node returns is live at once, which is how `filter` sends kept and discarded items down two paths in the same run. ## Conventions These Nodes Follow Four rules emerged while building the Tier 1 set. New nodes should follow them. **Fail loudly rather than drop data.** A node that cannot do what was asked throws, with a message naming what it received. `set-fields` throws when keep-all mode is handed an array instead of an object; its dotted paths refuse to overwrite a value they would otherwise destroy; `merge` throws when an item lacks the key it was told to combine on. The alternative - returning an empty object, or bucketing everything under the string `undefined` - produces a workflow that keeps running and quietly produces wrong data. **Guard a configurable output name against your own reserved keys.** A node that returns fixed keys alongside a user-named field must reject a name that would collide, throwing early: ```javascript if (outputField === 'count' || outputField === 'groups') { throw new Error('Aggregate: outputField cannot be "' + outputField + '", which is a reserved output name for this node. Pick another name.'); } ``` `aggregate`, `sort-limit-dedupe` and `datetime` all need this. `template` and `json` do not, because they return exactly one key and there is nothing to collide with - do not add the guard where it protects nothing. **Config values arrive already evaluated.** The engine resolves `{{...}}` in node config before `execute()` runs, and a config string that is exactly one expression keeps its native type. So a field typed as a string in the schema can legitimately hold an array at run time, which is why several nodes begin with `if (Array.isArray(inputField))`. That branch is not dead code. Nodes must not re-interpolate config values. **Read paths with the shared helper.** Use `smartbotic.utils.getFieldValue(data, path)` rather than writing a private path walker. Two older nodes, `if-condition` and `loop`, still carry their own divergent copies - `loop` silently skips a leading `data.` segment and `if-condition` does not - and that inconsistency is exactly what the shared helper exists to stop spreading. ## Available APIs Inside `execute()`, you have access to the `smartbotic` global object: ### Logging ```javascript smartbotic.log.debug('Debug message'); smartbotic.log.info('Info message'); smartbotic.log.warn('Warning message'); smartbotic.log.error('Error message'); ``` ### HTTP Requests ```javascript 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 } ``` ### Storage (Database) ```javascript // 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'); ``` ### Credentials ```javascript const auth = smartbotic.credentials.get(credentialId); if (auth.success) { // auth.headerName = 'Authorization' or 'X-API-Key' // auth.headerValue = 'Bearer ' or '' headers[auth.headerName] = auth.headerValue; } ``` ### Sending Email ```javascript const result = smartbotic.smtp.send({ credentialId: config.credentialId, // an smtp credential, or the imap one for the same mailbox to: ['someone@example.com'], // a single address or an array cc: [], bcc: [], subject: 'Subject line', body: 'Message text', html: false, // true sends the body as text/html replyTo: '', from: '', // overrides the sender on the credential fromName: '', attachments: [ { filename: 'note.txt', mimeType: 'text/plain', contentBase64: '...' } ] }); // result.messageId - the Message-ID the mail went out with // result.accepted - how many addresses it was submitted for ``` An SMTP credential holds `host`, `port`, `username`, `password`, `security` (`starttls`, `ssl` or `none`), and optionally `from_address` and `from_name`. An IMAP credential is accepted in its place: the same account is reached on the submission port with STARTTLS, so a mailbox stored for reading mail does not have to be entered again to send it. ### Utilities ```javascript // 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']); // Read a nested value by dotted path const city = smartbotic.utils.getFieldValue(input, 'data.user.address.city'); const second = smartbotic.utils.getFieldValue(input, 'data.items[1].id'); const howMany = smartbotic.utils.getFieldValue(input, 'data.items.length'); ``` Missing paths return `undefined`. Arrays are reached with `key[0]` or a bare numeric segment, and `.length` works on an array. The path is taken literally, with no special handling of a leading `data.` segment. ## Important Guidelines ### Indentation Use **4 spaces** for indentation (consistent with existing nodes). ### Avoid Inline Comments Do NOT use `//` comments inside schema definitions. The parser may incorrectly interpret them: ```javascript // 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' } } }; ``` ### String Quotes Use **single quotes** for string values in schemas. The parser converts them to JSON: ```javascript // GOOD title: 'My Title' // AVOID - embedded quotes can cause issues description: 'Use "quotes" carefully' // May break parsing ``` ### Error Handling **IMPORTANT**: Throw errors instead of returning `success: false`. This ensures the workflow fails properly: ```javascript 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. ## Migrating Nodes to Database After creating or modifying node files, migrate them to the running database: ```bash # 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. ## Example: HTTP Download Node ```javascript /** * @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 Trigger nodes start workflow executions. Add `@trigger` to the JSDoc: ```javascript /** * @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).