# 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. Must be a single line - the parser captures only the first line of a multi-line `@description` | | `@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. A top-level property's `default` is applied automatically whenever the stored config omits that key - both when the workflow is saved and again before the node executes - so `execute()` can rely on the key being present even for a workflow saved before the field existed. A default is resolved through the same expression evaluation as any other stored config value, so a `default:` containing `{{ }}` will be evaluated as an expression rather than kept as a literal string. No current node relies on this; keep it in mind if you're the first to try it. ### 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 }; ``` ## Pausing for an Answer A node pauses its execution by returning a `_pause` marker, the way a branching node returns `_activeBranch`: ```javascript return { token: token, reason: 'Approve the refund', expiresAt: Date.now() + 86400000, _pause: { token: token, reason: 'Approve the refund', expiresAt: expiresAt } }; ``` The engine stops the walk, stores the execution as `waiting` with everything computed so far, and returns. Nothing downstream runs. The marker is stripped from the stored output, so a reader sees the request rather than the mechanism. Answering it continues the run: ``` POST /api/v1/executions/{id}/resume { "token": "...", "approved": true, "data": { "note": "looks fine" } } ``` The paused node's output becomes that payload, and the walk continues from there. Nodes that already ran are not run again. The workflow is rebuilt from the snapshot stored with the execution, not from the workflow as it stands now, because it may have been edited while the approval waited. `GET /api/v1/executions/pending` lists executions waiting for an answer. It deliberately omits the token: listing is a weaker permission than approving. Two limits are deliberate. A pause inside a Loop body cannot be resumed, because loop iteration state is not part of the stored execution, so a node must refuse to pause there rather than record something unanswerable. And a webhook cannot wait for an approval - the HTTP request is still open and its deadline is 35 seconds - so a webhook-triggered workflow that pauses returns a 202 with `{ "executionId": "...", "status": "waiting" }` rather than blocking for an answer that has nowhere to arrive on this connection. Answer it the same way as any other paused execution, with `POST /api/v1/executions/{id}/resume`. ## Answering a Webhook A webhook-triggered workflow returns the last node's output as JSON by default. To control the response, return a `_webhookResponse` marker from any node: ```javascript return { _webhookResponse: { status: 201, headers: { 'Content-Type': 'application/json' }, body: { id: created.id } } }; ``` Any node may set it, not only the last one to run, so adding a node to the end of a workflow cannot silently change what its API returns. If several set it, the last one wins and the runner logs that it happened. ## Form Trigger `form-trigger` (category `triggers`) publishes an HTML form at a shareable URL. Submitting it starts the workflow - a form field lets a person do what a webhook otherwise needs a developer for. ### Where it is served The form is served at `/webhook/{workflowId}`. If the node's `path` field is set, that path is appended and must match exactly, e.g. `/webhook//apply`; leave it empty to answer at the bare workflow URL. `GET` renders the form, `POST` submits it. **The workflow must be published and active.** The webhook route only ever runs the published version - not whatever is currently open in the editor - and it refuses to answer at all if the workflow's `active` flag is off (a plain `400 Workflow is not active`). A form that was saved but never published, or was unpublished after being turned off, answers nothing useful. This is the single most likely reason a form "doesn't work": open the workflow and confirm both Publish and Active. ### Fields Each entry in the node's `fields` array is one question: | Key | Meaning | |-----|---------| | `name` | The key the workflow reads the answer under. Letters, digits and underscore, not starting with a digit. | | `label` | Shown next to the input. | | `type` | One of `text`, `textarea`, `number`, `select`, `checkbox`, `date`, `file`. | | `required` | Blocks submission (with the form re-rendered and an error) if left empty. | | `placeholder` | Placeholder text, for the text-like types. | | `options` | The choices, for a `select` field. | | `accept` | For a `file` field, e.g. `image/*`. This is a browser hint only, sent as the `accept` attribute on the `` - it is **not enforced server-side**. Nothing stops a submission with a different file type arriving anyway. A form that must reject a wrong file type needs a node downstream that inspects the actual bytes, not this field. | | `maxSizeMb` | For a `file` field. `0` uses the server-wide limit; a nonzero value can only tighten that limit, never loosen it - a per-field maximum above the server limit is meaningless because the oversized request is already refused before this check runs. | ### Reading the answers Answers land on the trigger node's output under `form`, keyed by field name: ``` {{ $node['Form'].form.email }} {{ $node['Form'].form.howMany }} ``` A `file` field arrives as a binary object, the same shape a download produces: ```json { "type": "binary", "data": "", "mimeType": "image/png", "filename": "photo.png", "size": 48213, "checksum": "" } ``` `checksum` is the SHA-256 of the base64 text, not of the raw bytes - the same convention `http-request`'s download path uses for its own binary output, so the two are directly comparable and an uploaded file can be handed straight to any node that expects a downloaded one. ### Response modes `responseMode` is `immediate` or `wait`: - **`immediate`** returns the thank-you page (`responseMessage`) right away and lets the workflow run in the background. **Anything that takes more than about 30 seconds must use this mode** - the alternative blocks the browser and times out. - **`wait`** holds the browser connection open until the workflow finishes, so a `respond-to-webhook` node downstream can shape the actual response (status, headers, body) the way [Answering a Webhook](#answering-a-webhook) describes. The wait is capped at 30 seconds; a run that has not finished by then answers with a timeout rather than hanging the browser indefinitely. Immediate-mode submissions run on a small bounded worker pool inside the webserver rather than one thread per request, so a burst of traffic against a public form cannot exhaust it. The pool is `server.form_dispatch_threads` (default 4) and `server.form_dispatch_queue_capacity` (default 32) in `config/webserver.json` - unset in the shipped config, so a stock install runs on those defaults. When the queue is full, the form answers **503** with a plain "This form is busy right now. Please try again in a moment." page instead of a false thank-you, so a bounded burst is visible as a retry-able failure rather than a silently dropped submission. ### Upload size An uploaded file is carried base64-encoded inside the gRPC message the webserver sends the runner, not as raw bytes - base64 is 4/3 the size of what it encodes, so a 24 MB file becomes roughly a 32 MB request. Keep that expansion in mind when sizing any of the limits below against an actual file size. Two limits apply on the way in, and a submission has to pass both: - `server.max_upload_mb` in `config/webserver.json` bounds every request body the webserver accepts, uploads included - 32 MB by default. Anything larger is refused before the form handler ever sees it. This is a limit on the HTTP request body, before base64 expansion. - The runner's own gRPC message-size limit, `max_message_size_mb` in `config/runner.json`, bounds the *base64-encoded* body again on its way from the webserver to the runner as part of the execution payload - 64 MB by default, deliberately set above the upload limit (with room for the base64 expansion) so it never re-imposes a lower cap than the one already enforced at the HTTP layer. A field's own `maxSizeMb` (see Fields, above) can tighten either of these further but never raise them. The way back is a separate limit, and it is deliberately not sized off either of the above: every gRPC channel the webserver opens to a runner - including the one a `wait`-mode form's execution result travels back over - bounds only what the webserver will *receive*, from `server.max_upload_mb`. Before this was wired up, that channel used gRPC's own 4 MB default for what it would accept back, so a `wait`-mode form whose `respond-to-webhook` body exceeded 4 MB failed with "Received message larger than max". What the webserver *sends* to a runner over that same channel is left at gRPC's default (unlimited) on purpose: a send cap taken from `max_upload_mb` would be smaller than a base64-encoded upload that `max_upload_mb` itself allows (a 26 MiB file base64-encodes to about 34.7 MB, over a 32 MB cap), and the two limits above already bound what the webserver will accept from a client and what the runner will accept from the webserver - a third cap on the way out would protect nothing and would only break a documented-legal upload. ### Password `password` is optional; leave it empty and anyone with the link can submit the form. Set it and a visitor must enter it once (checked with a constant-time comparison) before the form is shown; on success they get a signed, HttpOnly cookie scoped to that workflow's webhook path, good for one hour, so they are not asked again for the rest of the session. **State plainly what this is worth**: the password is stored as plain, readable text in the live workflow document. Anyone who can read the workflow - anyone with editor access to the project - can read it directly, no decryption involved. It is a gate against a link being forwarded somewhere it shouldn't go, not a secret and not a security boundary. Do not rely on it to keep a form private from someone who already has access to the workspace. The session cookie itself is also not marked `Secure`. That is deliberate - this is meant to work for a plain-HTTP LAN deployment with no certificate at all - but it means the cookie would be sent over plain HTTP even when an HTTPS reverse proxy sits in front of the webserver. Deploying behind such a proxy is a real constraint to plan around, not something this form handles for you. ### No captcha There is no captcha and no rate limiting on the form endpoint beyond the bounded worker pool described above. A public form can be submitted repeatedly by a script as easily as by a person. A form that cannot tolerate that should be given a password, at minimum, and treated as a mitigation rather than a fix. ### Submissions are executions There is no separate submissions list. A submission is a workflow execution like any other and is visible wherever executions are - the workflow's execution history - with the form answers as the trigger node's output. ### Two things to know before debugging one at 2am **The form endpoint reveals that a form exists at a URL, even to someone who doesn't know its password.** An unknown workflow id gets a `404`, a workflow that exists but has no form node falls through to a structurally different response, and a workflow with a form node behind a wrong password gets a `200` password prompt. These three cases are distinguishable, so a visitor can tell "there is a form here" from "there is nothing here" without ever seeing the password itself. This was a deliberate choice, not an oversight - closing it would mean reshaping how every webhook on the platform answers a request for an id it doesn't recognize, and workflow ids are random UUIDs, so there is nothing practical to enumerate against it in the first place. **A node with a config field literally named `password` - the form trigger included - permanently shows as having unpublished changes in the editor,** even immediately after publishing. The upstream database daemon encrypts fields named `password` when it writes a version-history snapshot, while a live read of the same document stays plaintext; the editor's unpublished-changes indicator compares those two, and an encrypted value never equals its own plaintext. This is pre-existing platform behaviour that affects any node config with a `password`-named field, not something specific to forms - it is safe to ignore the indicator on a form-trigger node once you've confirmed the publish itself succeeded. ## 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' || (groupBy && outputField === 'key')) { 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. When a node builds its reserved-key list dynamically, as `aggregate` does for `key` (only reserved once `groupBy` is set, since that is the only time it is written), check every mode the node has, not just the always-on keys. **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. `template` is the one deliberate exception: its `templateSource: 'input'` mode reads template text out of the input DATA at run time, which the engine never walks, so that text still has its `{{...}}` placeholders intact when `execute()` sees it, and calling `smartbotic.utils.interpolate` on it is genuine work, not a re-interpolation of something the engine already resolved. **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, dataBase64 } ``` #### Sending and receiving binary `body` is a text field. It is read as a UTF-8 string, so any byte sequence that is not valid UTF-8 is replaced on the way out - an image sent that way uploads successfully and arrives corrupt. There are two binary-safe paths instead. For an API that takes a bare binary payload, such as an S3 PUT or the AT Protocol blob upload, use `bodyBase64`. It is decoded to raw bytes and sent as the whole body, and it wins over `body` when both are given: ```javascript const file = smartbotic.storage.downloadFile(fileId); smartbotic.http.request({ method: 'POST', url: 'https://bsky.social/xrpc/com.atproto.repo.uploadBlob', headers: { 'Content-Type': 'image/jpeg' }, bodyBase64: file.data }); ``` For an API that expects a form upload, use `files` with `formData`, which builds a multipart body - see `nodes/integration/telegram-send.js`. On the way back, `data` is also UTF-8 text and cannot carry binary. Read `dataBase64` for image or file downloads. One trap worth knowing: `utils.base64Encode` encodes a JavaScript string as UTF-8, so it is not a byte-preserving round trip for arbitrary bytes you build in JavaScript with `String.fromCharCode`. Anything at or above 0x80 becomes two bytes. Binary should come from the file store, where it never passes through a JavaScript string, rather than being assembled by hand. ### 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'); ``` `http-request` (Store Download) and `imap-extract-attachments` (Store in Database) both write through `smartbotic.storage.insert` with a TTL. Both default that TTL field to 24 hours, so downloaded files and extracted attachments stored by those nodes now expire and are auto-deleted a day after they're stored, unless the workflow sets the TTL field to `0` explicitly - `0` means keep forever. Any workflow that was relying on permanent storage under an unset TTL field needs that `0` set explicitly, or it will start losing data a day later. ### Filesystem ```javascript // Read a file const read = smartbotic.fs.readFile('/tmp/example.txt', 'base64'); // { success, data } - data is base64-encoded when the encoding argument is 'base64' // Write a file smartbotic.fs.writeFile('/tmp/example.txt', base64Data, 'base64'); // Check existence const there = smartbotic.fs.exists('/tmp/example.txt'); // Stat a file const info = smartbotic.fs.stat('/tmp/example.txt'); // { success, size, mtime, isDirectory } - mtime is milliseconds since the epoch // Create a directory smartbotic.fs.mkdir('/tmp/example-dir'); // Delete a file smartbotic.fs.unlink('/tmp/example.txt'); // List a directory, one level const listing = smartbotic.fs.readdir('/var/spool/incoming'); // { success: true, entries: [{ name, path, size, modifiedAt, isDirectory }] } ``` The `fs` API applies no path restrictions. Every call takes an arbitrary absolute path and acts on it with the runner process's own permissions, the same trust level a `code` node or `process.exec` already has. ### 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). ## Chat nodes for OpenAI-compatible providers OpenRouter, Together, Groq, DeepSeek, Mistral, xAI, Fireworks, Perplexity, DeepInfra and OpenAI itself all accept the same chat request and return the same reply. One implementation covers all of them, in `scripts/gen-openai-compatible-nodes.py`, which writes one node per provider. A node cannot require another node's file, so the code is generated rather than shared at runtime - edit the generator, run it, commit the diff. Adding a provider is a row in the `PROVIDERS` table: its id, display name, base URL, a sensible default model, and where its keys come from. Set `no_model_list` when the provider publishes no `/models` endpoint, and the model field becomes a plain box rather than an empty dropdown. Each node registers a credential type of its own, so a key for one provider is never offered to another - and any of them will still take a plain bearer credential, because the shape is the same. The base URL is a setting, so any of these nodes also reaches a proxy, a gateway or a self-hosted service that speaks the same API. ## Retrying an HTTP request `smartbotic.http.request` takes retry options, so a node does not need its own loop and its own sleep: ```javascript smartbotic.http.request({ method: 'GET', url: feedUrl, timeout: 30000, retries: 2, // extra attempts, 0 (off) unless asked for retryDelayMs: 2000, // doubles each attempt retryMaxDelayMs: 30000, // and is capped here retryOnStatus: [429, 503] // defaults to 408, 425, 429, 500, 502, 503, 504 }); ``` Retried: a timeout, a refused connection, a name that would not resolve, and the statuses above. Not retried: a 404 or a 401 - those are answers, and asking again does not change them. A `Retry-After` header from the server wins over the backoff, capped at `retryMaxDelayMs`, because being told to wait and then not waiting is how a rate limit becomes a ban. It is off by default on purpose. A POST that timed out may already have been received at the far end, and repeating it would do the thing twice - so the caller opts in where repeating is safe. ## Running the verification cases ```bash ./scripts/run-node-tests.sh # all of them python3 scripts/verify-node.py tests/nodes/.json # one ``` Exit 0 means every assertion held, 1 means one did not, and 2 means the case declares a precondition this machine does not meet. The runner counts that last one as skipped and names it. A case that cannot run is never counted as passed: a green tally that includes tests which did not run is worse than a red one. A case declares what it needs with `requires`: ```json "requires": { "http": "http://mulan:8077/health", "expect": { "model_loaded": true } } ``` Two SD.cpp cases use this. They compare against whatever model the server has loaded, and that server unloads when idle - so without the gate they fail for a reason that has nothing to do with the code, and a failure everyone learns to ignore is worse than no test.