Custom Nodes

Creating Custom Nodes

Voodflow ships a CLI scaffolding system to create and build custom nodes. A node is a self-contained unit composed of three files:

File Role
{NodeClass}.php PHP execution logic
components/{NodeClass}.jsx React UI component displayed in the canvas
manifest.json Metadata, display settings, config schema, licensing

Overview: The Two-Command Workflow

TEXT
php artisan voodflow:make-node MyNode       # 1. Scaffold
php artisan voodflow:build-node MyNode      # 2. Compile JSX → JS bundle

Step 1 — Scaffold: voodflow:make-node

BASH
php artisan voodflow:make-node {name}
Option Description
{name} Node class name (e.g. SlackNode, OpenAiNode)
--interactive Force fully interactive mode
--force Overwrite existing files

The command launches an interactive wizard that collects:

Wizard Prompts

1. Node type

TEXT
  trigger      — Starts a workflow (scheduled, event-driven, webhook)
  action       — Performs an operation (send, create, call)
  transform    — Reshapes or maps data
  flow         — Controls execution branching (if, switch, loop)
  data         — Reads or writes to a data store
  utility      — Developer tools (debug, code, inspect)
  ai           — AI/ML integrations
  notification — Sends alerts or messages
  integration  — Third-party API connectors

The type determines the default color, icon, and canvas group.

2. Tier

TEXT
  FREE — Public node (default)
  PRO  — Paid node (requires licensing)

Voodflow also ships internal CORE nodes, but CORE scaffolding is not part of the public node authoring flow.

3. Author, description, optional metadata

  • Author name and URL
  • Short description (shown in the node picker)
  • Repository URL
  • License (default: MIT)
  • Logo filename (for a custom image icon instead of a Heroicon)

4. Multiple outputs (flow nodes only)

If the node type is flow, the wizard asks whether it has multiple output handles (like an IF or Switch node). You define each handle's ID, label, and color.

Output

After the wizard completes, the following structure is created:

TEXT
packages/voodflow/voodflow/src/Nodes/Custom/{NodeClass}/
├── {NodeClass}.php
├── components/
│   └── {NodeClass}.jsx
└── manifest.json

Step 2 — Implement the PHP class

The generated {NodeClass}.php implements NodeInterface. Fill in the execute() method.

For a simple reference implementation, look at an existing core node such as DummyDataNode:

  • packages/voodflow/voodflow/src/Nodes/DummyDataNode/DummyDataNode.php

Minimal example: a simple transform node

This example reads a config value, resolves {{tags}}, and returns a merged output.

PHP
<?php

namespace Voodflow\Voodflow\Nodes\UppercaseNode;

use Voodflow\Voodflow\Contracts\NodeInterface;
use Voodflow\Voodflow\Execution\ExecutionContext;
use Voodflow\Voodflow\Execution\ExecutionResult;

class UppercaseNode implements NodeInterface
{
    public static function type(): string
    {
        return 'uppercase_node';
    }

    public static function defaultConfig(): array
    {
        return [
            'label' => 'Uppercase',
            'description' => '',
            'source_path' => 'text',
            'target_key' => 'uppercased',
        ];
    }

    public function execute(ExecutionContext $context): ExecutionResult
    {
        $sourcePath = $context->getConfig('source_path', 'text');
        $targetKey = $context->getConfig('target_key', 'uppercased');

        $value = data_get($context->input, $sourcePath);
        $value = is_string($value) ? $value : (string) $value;

        $resolved = $context->resolveTemplate($value);

        return ExecutionResult::success(array_merge($context->input, [
            $targetKey => mb_strtoupper($resolved),
        ]));
    }

    public function validate(array $config): array
    {
        $errors = [];

        if (empty($config['source_path'])) {
            $errors['source_path'] = 'Source path is required';
        }

        return $errors;
    }

    public static function definition(): array
    {
        return [
            ['key' => 'source_path', 'type' => 'text', 'label' => 'Source Path', 'required' => true],
            ['key' => 'target_key', 'type' => 'text', 'label' => 'Target Key', 'required' => true],
        ];
    }

    public static function supportsRetry(): bool
    {
        return true;
    }
}

ExecutionContext API

Method / Property Type Description
$context->input array Output of the previous node
$context->getConfig(string $key, mixed $default) mixed Read a node config value
$context->resolveTemplate(string $template) string Resolve {{tags}} against context
$context->getVariable(string $key) mixed Read a workflow-level variable
$context->setVariable(string $key, mixed $value) void Write a workflow-level variable
$context->getCredential(int $id, string $field) mixed Decrypt and return a credential field
$context->executionId int Current execution ID
$context->workflow Workflow The Workflow Eloquent model

ExecutionResult API

PHP
// Pass data to the default output
ExecutionResult::success(array $data): ExecutionResult

// Terminate branch with error (interceptable by Catch node)
ExecutionResult::failure(string $message, string $code = 'ERROR'): ExecutionResult

// Route to a named output handle (flow nodes)
ExecutionResult::success($data)->toOutput('true')
ExecutionResult::success($data)->toOutput('false')

Step 3 — Customize the React component

The generated JSX file is a React component that integrates with the Voodflow canvas design system. You typically build the UI by composing a few shared primitives:

JSX
import {
    BaseNodeContainer,
    NodeHeader,
    NodeConfigReadOnlyNotice,
    NodeNonExecutableNotice,
    CollapsedView,
    NodeConfigFields,
    FieldLabel,
    TextInput,
    SelectInput,
    StandardHandle,
    NODE_CONFIG_DEBOUNCE_MS,
    useStandardNodeBehavior,
    useNodeAccessUi,
} from "../../../../resources/js/components/nodes";

What these primitives do

Import What it’s for
BaseNodeContainer Standard outer wrapper: selection states, border, background, and node color accents
NodeHeader Title bar: icon, label, tier badge, expand/collapse, and common actions
CollapsedView Compact summary shown when the node is collapsed
NodeConfigFields The standard “Label + Description” UI block used across nodes
FieldLabel Consistent label typography and spacing for custom fields
TextInput, SelectInput Styled inputs that match the canvas UI (including dark mode)
StandardHandle The standard ReactFlow handle with consistent styling and positioning
NODE_CONFIG_DEBOUNCE_MS The default debounce used to avoid spamming Livewire updates while typing
useStandardNodeBehavior Shared behavior hook (expanded state, selection, common update helpers)
useNodeAccessUi Centralized access-control UI flags (workflow read-only, node read-only, delete gating, executable)
NodeConfigReadOnlyNotice Policy-only banner when a node configuration is read-only for the current role
NodeNonExecutableNotice Banner when a node is disabled during execution for the current role

When node access control is enabled, generated nodes follow these conventions:

  • Disable editing: use configReadOnly from useNodeAccessUi(data) to set readOnly={configReadOnly} / disabled={configReadOnly} and to guard debounced saves (if (configReadOnly) return;).
  • Show notices: render NodeConfigReadOnlyNotice and NodeNonExecutableNotice at the top of the expanded node body.
  • Delete gating: pass onDelete={canShowDeleteButton ? ... : undefined} to NodeHeader so non-deletable nodes do not show the delete icon or modal.

Adding a configuration field

  1. Add state:
JSX
const [channel, setChannel] = useState(data.channel || "");
  1. Add JSX in the expanded view:
JSX
<div className="px-4 pb-4 border-t border-slate-100 dark:border-slate-800 pt-4 space-y-4">
    <div className="space-y-1">
        <FieldLabel>Slack Channel</FieldLabel>
        <TextInput
            value={channel}
            onChange={(e) => setChannel(e.target.value)}
            placeholder="#general"
            color={nodeColor}
        />
    </div>
</div>
  1. Persist in the debounced useEffect:
JSX
component.call("updateNodeConfig", { nodeId: id, label, description, channel });
// and in setNodes: data: { ...node.data, label, description, channel }
// add `channel, data.channel` to the dependency array

Available UI primitives

Component Use
TextInput Single-line text
SelectInput Dropdown (pass options={[...]})
FieldLabel Label above a field
NodeConfigFields Standard label + description block
StandardHandle Input or output connection handle
CollapsedView Summary shown when node is collapsed
BaseNodeContainer Root wrapper with color/selection styles
NodeHeader Title bar with icon, badge, expand/delete

Step 4 — Build: voodflow:build-node

BASH
php artisan voodflow:build-node ChatNode

What happens:

  1. Locates components/ChatNode.jsx
  2. Compiles with esbuild — React, ReactDOM, and ReactFlow are externalized
  3. Writes bundle to {node_dir}/dist/chat-node.js
  4. Copies bundle to public/js/voodflow/nodes/chat-node.js

After building, refresh your browser — the node appears in the canvas picker.

The bundle registers itself as window.VoodflowNode_ChatNode. Voodflow auto-discovers all bundles in public/js/voodflow/nodes/.


The manifest.json Reference

JSON
{
    "name": "chat-node",
    "display_name": "Chat",
    "version": "1.0.0",
    "author": "Acme Corp",
    "author_url": "https://acme.example.com",
    "description": "Post messages to chat channels",
    "category": "integration",
    "tier": "CORE",

    "display": {
        "iconType": "heroicon",
        "icon": "heroicon-o-chat-bubble-left-right",
        "color": "indigo",
        "logo": null
    },

    "php": {
        "class": "ChatNode",
        "namespace": "Voodflow\\Voodflow\\Nodes\\ChatNode"
    },

    "javascript": {
        "component": "VoodflowNode_ChatNode",
        "bundle": "dist/chat-node.js"
    },

    "voodflow": {
        "min_version": "1.0.0"
    },

    "license": {
        "type": "MIT"
    }
}

Field Reference

Field Type Description
name string Kebab-case identifier, e.g. chat-node
display_name string Human-readable name shown in the UI
version string SemVer version
author string Author or organization name
description string Short description for the node picker
category string trigger action transform flow data utility ai notification integration
tier string FREE | PRO
display.iconType string heroicon or image
display.icon string Heroicon name or image filename
display.color string Tailwind color: blue indigo amber emerald violet cyan etc.
display.logo string|null Logo filename placed in the node directory (iconType: image)
php.class string PHP class name
php.namespace string Fully qualified namespace
javascript.component string window variable name of the bundle
javascript.bundle string Relative path to the compiled JS file
voodflow.min_version string Minimum Voodflow version required
license.type string License identifier (e.g. MIT)
function string Functional role for plugin nodes that stay in the Integrations tab (see below)

Plugin accent color (function)

Core nodes take their canvas color from category. Plugin nodes stay in the Integrations library tab, but you can set function (or display.function) to the same role key so they share the core accent:

Role Color
trigger amber
action blue
transform / data emerald
flow yellow
human / ai violet
notification indigo
integration cyan
utility slate

display.color still wins when you set it explicitly.


Conditional fields (visibleWhen)

NodeDefinitionBuilder::withVisibleWhen() hides the last field you added until a sibling field equals a value. Select fields keep a blank “Select…” option, and text fields can show description under the input.

PHP
$builder
    ->addSelectField('audience', 'Audience', ['email' => 'Email', 'slack' => 'Slack'], required: true)
    ->addTextField('email', 'Email address', description: 'Used when Audience is Email')
    ->withVisibleWhen('audience', 'email');

Development Tips

Hot-reload workflow

BASH
# Edit your PHP or JSX, then:
php artisan voodflow:build-node MyNode
# → Refresh browser

Naming conventions

Artifact Convention Example
PHP class PascalCase + Node suffix LlmChatNode
manifest.name kebab-case llm-chat-node
type() return snake_case llm_chat_node
JSX component PascalCase LlmChatNode

Type key uniqueness

The value returned by type() must be globally unique. Always prefix it:

PHP
// Good
public static function type(): string { return 'acme_chat_node'; }

// Bad — may conflict
public static function type(): string { return 'chat'; }

Complete Example: Simple Transform Node

BASH
# 1. Scaffold
php artisan voodflow:make-node UppercaseNode
# Type: transform | Tier: CORE | Author: Your Name

UppercaseNode.php — execute method:

PHP
public function execute(ExecutionContext $context): ExecutionResult
{
    $sourcePath = $context->getConfig('source_path', 'text');
    $targetKey = $context->getConfig('target_key', 'uppercased');

    $value = data_get($context->input, $sourcePath);
    $value = is_string($value) ? $value : (string) $value;

    $resolved = $context->resolveTemplate($value);

    return ExecutionResult::success(array_merge($context->input, [
        $targetKey => mb_strtoupper($resolved),
    ]));
}
BASH
# 2. Build
php artisan voodflow:build-node UppercaseNode

# 3. Test in canvas: connect after Dummy Data, set source_path = "text"
# 4. Inspect output with a Data Viewer node