Extending Workflows

Any module can extend the Workflows system by registering custom nodes. This allows you to create domain-specific triggers and actions that integrate with your module's functionality.

Architecture Overview

┌────────────────────────────────────────────────────────────────────────┐
│                         Workflows Module                                │
│   ┌──────────────────────────────────────────────────────────────────┐│
│   │                      NodeRegistry                                 ││
│   │   - workflows:manual-trigger                                      ││
│   │   - workflows:send-email                                          ││
│   │   - workflows:http-request                                        ││
│   │   - ...                                                           ││
│   └──────────────────────────────────────────────────────────────────┘│
└────────────────────────────────────────────────────────────────────────┘
                                    ▲
                                    │ register()
                                    │
┌───────────────────────────────────┴────────────────────────────────────┐
│                          Your Module                                    │
│   ┌──────────────────────────────────────────────────────────────────┐│
│   │  OrdersServiceProvider                                           ││
│   │    - registerWorkflowNodes()                                     ││
│   │      └─► NodeRegistry::registerMany([                            ││
│   │            OrderCreatedTrigger::class,                           ││
│   │            UpdateOrderStatusAction::class,                       ││
│   │          ])                                                      ││
│   └──────────────────────────────────────────────────────────────────┘│
│   ┌──────────────────────────────────────────────────────────────────┐│
│   │  Nodes/                                                          ││
│   │    Triggers/                                                     ││
│   │      - OrderCreatedTrigger.php                                   ││
│   │      - OrderStatusChangedTrigger.php                             ││
│   │    Actions/                                                      ││
│   │      - UpdateOrderStatusAction.php                               ││
│   └──────────────────────────────────────────────────────────────────┘│
└────────────────────────────────────────────────────────────────────────┘

Step 1: Create the Node Contract

All nodes must implement WorkflowNodeContract:

<?php

namespace Modules\Workflows\Contracts;

interface WorkflowNodeContract
{
    // Unique identifier: "module:node-name"
    public static function getIdentifier(): string;

    // Display name
    public static function getName(): string;

    // Category: trigger, action, condition, transformer
    public static function getCategory(): string;

    // Description for UI
    public static function getDescription(): string;

    // Lucide icon name
    public static function getIcon(): string;

    // Node color (hex)
    public static function getColor(): string;

    // Module alias
    public static function getModule(): string;

    // Input port definitions
    public static function getInputs(): array;

    // Output port definitions
    public static function getOutputs(): array;

    // JSON Schema for configuration
    public static function getConfigSchema(): array;

    // Default configuration values
    public static function getDefaultConfig(): array;

    // Validate configuration
    public function validateConfig(array $config): array;

    // Execute the node
    public function execute(array $input, array $config, array $context): array;

    // Check compatibility with other nodes
    public static function isCompatibleWith(string $targetNodeType): bool;
}

Step 2: Create Your Node Class

Using BaseNode (Recommended)

Extend BaseNode for common functionality:

<?php

namespace Modules\Orders\Nodes\Triggers;

use Modules\Workflows\Nodes\BaseNode;
use Modules\Orders\App\Models\Order;

class OrderCreatedTrigger extends BaseNode
{
    public static function getIdentifier(): string
    {
        return 'orders:order-created';
    }

    public static function getName(): string
    {
        return 'Order Created';
    }

    public static function getCategory(): string
    {
        return 'trigger';
    }

    public static function getDescription(): string
    {
        return 'Triggers when a new order is created';
    }

    public static function getIcon(): string
    {
        return 'ShoppingCart';
    }

    public static function getColor(): string
    {
        return '#22C55E'; // Green for triggers
    }

    public static function getModule(): string
    {
        return 'orders';
    }

    public static function getInputs(): array
    {
        // Triggers have no inputs
        return [];
    }

    public static function getOutputs(): array
    {
        return [
            [
                'name' => 'output',
                'label' => 'Order Data',
                'type' => 'object',
            ],
        ];
    }

    public static function getConfigSchema(): array
    {
        return [
            'type' => 'object',
            'properties' => [
                'orderStatuses' => [
                    'type' => 'array',
                    'title' => 'Order Statuses',
                    'description' => 'Only trigger for these statuses (empty = all)',
                    'items' => ['type' => 'string'],
                ],
            ],
        ];
    }

    public static function getDefaultConfig(): array
    {
        return [
            'orderStatuses' => [],
        ];
    }

    public function validateConfig(array $config): array
    {
        // No required config for this trigger
        return [];
    }

    public function execute(array $input, array $config, array $context): array
    {
        // For event triggers, $input contains the event payload
        $orderId = $input['order_id'] ?? null;

        if (!$orderId) {
            throw new \Exception('Order ID is required');
        }

        // Load the order
        $order = Order::with(['customer', 'items'])->find($orderId);

        if (!$order) {
            throw new \Exception("Order not found: {$orderId}");
        }

        // Check status filter
        $statusFilter = $config['orderStatuses'] ?? [];
        if (!empty($statusFilter) && !in_array($order->status, $statusFilter)) {
            throw new \Exception("Order status '{$order->status}' not in allowed list");
        }

        // Return order data for next nodes
        return [
            'order' => [
                'id' => $order->id,
                'status' => $order->status,
                'total' => $order->total,
                'currency' => $order->currency,
                'created_at' => $order->created_at->toISOString(),
            ],
            'customer' => [
                'id' => $order->customer->id,
                'email' => $order->customer->email,
                'name' => $order->customer->name,
                'phone' => $order->customer->phone,
            ],
            'items' => $order->items->map(fn($item) => [
                'product_id' => $item->product_id,
                'name' => $item->name,
                'quantity' => $item->quantity,
                'price' => $item->price,
            ])->toArray(),
        ];
    }
}

Creating an Action Node

<?php

namespace Modules\Orders\Nodes\Actions;

use Modules\Workflows\Nodes\BaseNode;
use Modules\Orders\App\Models\Order;

class UpdateOrderStatusAction extends BaseNode
{
    public static function getIdentifier(): string
    {
        return 'orders:update-order-status';
    }

    public static function getName(): string
    {
        return 'Update Order Status';
    }

    public static function getCategory(): string
    {
        return 'action';
    }

    public static function getDescription(): string
    {
        return 'Update the status of an order';
    }

    public static function getIcon(): string
    {
        return 'RefreshCw';
    }

    public static function getColor(): string
    {
        return '#3B82F6'; // Blue for actions
    }

    public static function getModule(): string
    {
        return 'orders';
    }

    public static function getInputs(): array
    {
        return [
            [
                'name' => 'input',
                'label' => 'Input',
                'type' => 'object',
                'required' => true,
            ],
        ];
    }

    public static function getOutputs(): array
    {
        return [
            [
                'name' => 'output',
                'label' => 'Output',
                'type' => 'object',
            ],
        ];
    }

    public static function getConfigSchema(): array
    {
        return [
            'type' => 'object',
            'properties' => [
                'orderId' => [
                    'type' => 'string',
                    'title' => 'Order ID',
                    'description' => 'Expression to get order ID (e.g., {{order.id}})',
                ],
                'newStatus' => [
                    'type' => 'string',
                    'title' => 'New Status',
                    'enum' => ['pending', 'processing', 'shipped', 'delivered', 'cancelled'],
                ],
                'notifyCustomer' => [
                    'type' => 'boolean',
                    'title' => 'Notify Customer',
                    'default' => true,
                ],
            ],
            'required' => ['orderId', 'newStatus'],
        ];
    }

    public static function getDefaultConfig(): array
    {
        return [
            'notifyCustomer' => true,
        ];
    }

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

        if (empty($config['orderId'])) {
            $errors[] = 'Order ID is required';
        }

        if (empty($config['newStatus'])) {
            $errors[] = 'New status is required';
        }

        return $errors;
    }

    public function execute(array $input, array $config, array $context): array
    {
        // Resolve expressions in config
        $orderId = $this->resolveExpression($config['orderId'], $input);
        $newStatus = $config['newStatus'];
        $notifyCustomer = $config['notifyCustomer'] ?? true;

        // Find and update the order
        $order = Order::findOrFail($orderId);
        $oldStatus = $order->status;

        $order->update([
            'status' => $newStatus,
        ]);

        // Optionally notify customer
        if ($notifyCustomer) {
            // Dispatch notification job
            // NotifyCustomerOrderStatus::dispatch($order);
        }

        // Return what this node produced — not a copy of $input.
        // See "Output Conventions" for why, and for how a downstream node
        // still reaches the original payload via {{trigger_data.*}}.
        return [
            '_order_updated' => true,
            '_old_status' => $oldStatus,
            '_new_status' => $newStatus,
            '_updated_at' => now()->toISOString(),
            'order' => $order->fresh()->toArray(),
        ];
    }
}

Creating a Condition Node

<?php

namespace Modules\Orders\Nodes\Conditions;

use Modules\Workflows\Nodes\BaseNode;

class OrderValueCondition extends BaseNode
{
    public static function getIdentifier(): string
    {
        return 'orders:order-value-check';
    }

    public static function getName(): string
    {
        return 'Order Value Check';
    }

    public static function getCategory(): string
    {
        return 'condition';
    }

    public static function getDescription(): string
    {
        return 'Branch based on order total value';
    }

    public static function getIcon(): string
    {
        return 'DollarSign';
    }

    public static function getColor(): string
    {
        return '#F59E0B'; // Yellow for conditions
    }

    public static function getModule(): string
    {
        return 'orders';
    }

    public static function getInputs(): array
    {
        return [
            ['name' => 'input', 'label' => 'Input', 'type' => 'object', 'required' => true],
        ];
    }

    public static function getOutputs(): array
    {
        return [
            ['name' => 'high', 'label' => 'High Value', 'type' => 'object', 'conditional' => true],
            ['name' => 'low', 'label' => 'Low Value', 'type' => 'object', 'conditional' => true],
        ];
    }

    public static function getConfigSchema(): array
    {
        return [
            'type' => 'object',
            'properties' => [
                'threshold' => [
                    'type' => 'number',
                    'title' => 'Threshold Amount',
                    'description' => 'Orders above this are "high value"',
                    'default' => 100,
                ],
                'valuePath' => [
                    'type' => 'string',
                    'title' => 'Value Path',
                    'description' => 'Path to order total (e.g., order.total)',
                    'default' => 'order.total',
                ],
            ],
            'required' => ['threshold'],
        ];
    }

    public static function getDefaultConfig(): array
    {
        return [
            'threshold' => 100,
            'valuePath' => 'order.total',
        ];
    }

    public function execute(array $input, array $config, array $context): array
    {
        $threshold = $config['threshold'] ?? 100;
        $valuePath = $config['valuePath'] ?? 'order.total';

        // Get the value using dot notation
        $value = data_get($input, $valuePath, 0);

        $isHighValue = $value >= $threshold;

        // `_branch` tells the engine which outgoing edges may fire. Return it
        // alongside this node's own findings rather than merged into a copy
        // of $input.
        return [
            '_branch' => $isHighValue ? 'high' : 'low',
            '_order_value' => $value,
            '_threshold' => $threshold,
            '_is_high_value' => $isHighValue,
        ];
    }
}

Step 3: Register Nodes in ServiceProvider

<?php

namespace Modules\Orders;

use Illuminate\Support\ServiceProvider;
use Modules\Workflows\App\Services\NodeRegistry;

class OrdersServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // ... other boot logic

        $this->registerWorkflowNodes();
    }

    protected function registerWorkflowNodes(): void
    {
        // Check if Workflows module is installed
        if (!class_exists(NodeRegistry::class)) {
            return;
        }

        $registry = $this->app->make(NodeRegistry::class);

        $registry->registerMany([
            // Triggers
            \Modules\Orders\Nodes\Triggers\OrderCreatedTrigger::class,
            \Modules\Orders\Nodes\Triggers\OrderStatusChangedTrigger::class,

            // Actions
            \Modules\Orders\Nodes\Actions\UpdateOrderStatusAction::class,
            \Modules\Orders\Nodes\Actions\CreateOrderNoteAction::class,

            // Conditions
            \Modules\Orders\Nodes\Conditions\OrderValueCondition::class,
        ]);
    }
}

Step 4: Fire Events for Triggers

For event-based triggers, fire workflow events from your module:

<?php

namespace Modules\Orders\App\Observers;

use Modules\Orders\App\Models\Order;
use Modules\Workflows\App\Services\WorkflowService;

class OrderObserver
{
    protected WorkflowService $workflowService;

    public function __construct(WorkflowService $workflowService)
    {
        $this->workflowService = $workflowService;
    }

    public function created(Order $order): void
    {
        // Trigger workflows that use orders:order-created
        $this->workflowService->triggerByEvent('orders:order-created', [
            'order_id' => $order->id,
        ]);
    }

    public function updated(Order $order): void
    {
        if ($order->wasChanged('status')) {
            // Trigger workflows that use orders:order-status-changed
            $this->workflowService->triggerByEvent('orders:order-status-changed', [
                'order_id' => $order->id,
                'old_status' => $order->getOriginal('status'),
                'new_status' => $order->status,
            ]);
        }
    }
}

Register the observer:

// In OrdersServiceProvider::boot()
Order::observe(OrderObserver::class);

BaseNode Helper Methods

The BaseNode class provides useful helper methods:

Expression Resolution

// Resolve {{expressions}} in strings
$email = $this->resolveExpression('{{customer.email}}', $input);

// Resolve in arrays recursively
$config = $this->resolveExpressions($config, $input);

Data Access

// Get nested value with dot notation
$total = data_get($input, 'order.total', 0);

// Set nested value
data_set($output, 'result.success', true);

Validation Helpers

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

    // Required field
    if (empty($config['field'])) {
        $errors[] = 'Field is required';
    }

    // Type check
    if (isset($config['amount']) && !is_numeric($config['amount'])) {
        $errors[] = 'Amount must be a number';
    }

    // Enum check
    $validStatuses = ['pending', 'active', 'closed'];
    if (!in_array($config['status'] ?? '', $validStatuses)) {
        $errors[] = 'Invalid status';
    }

    return $errors;
}

Config Schema Reference

Use JSON Schema for configuration:

Basic Types

public static function getConfigSchema(): array
{
    return [
        'type' => 'object',
        'properties' => [
            // String
            'name' => [
                'type' => 'string',
                'title' => 'Name',
                'description' => 'Enter a name',
                'minLength' => 1,
                'maxLength' => 255,
            ],

            // Number
            'amount' => [
                'type' => 'number',
                'title' => 'Amount',
                'minimum' => 0,
                'maximum' => 10000,
            ],

            // Integer
            'count' => [
                'type' => 'integer',
                'title' => 'Count',
                'default' => 1,
            ],

            // Boolean
            'enabled' => [
                'type' => 'boolean',
                'title' => 'Enabled',
                'default' => true,
            ],

            // Enum (dropdown)
            'priority' => [
                'type' => 'string',
                'title' => 'Priority',
                'enum' => ['low', 'medium', 'high'],
                'default' => 'medium',
            ],

            // Textarea
            'description' => [
                'type' => 'string',
                'title' => 'Description',
                'format' => 'textarea',
            ],

            // Array
            'tags' => [
                'type' => 'array',
                'title' => 'Tags',
                'items' => ['type' => 'string'],
            ],

            // Object
            'settings' => [
                'type' => 'object',
                'title' => 'Settings',
                'properties' => [
                    'key' => ['type' => 'string'],
                ],
            ],
        ],
        'required' => ['name', 'amount'],
    ];
}

How a Schema Becomes a Form

You never write frontend code for a node's configuration. The builder renders the form from your schema, which is the whole point of the node contract: a module contributes nodes without touching the Workflows module or the Next.js app.

NodeRegistry runs every schema through NodeSchemaNormalizer and serves the result as config_schema alongside the raw configSchema. The panel renders config_schema.

Two accepted formats

JSON Schema — preferred. Use this for new nodes. It is also what validation, API consumers and external tooling read.

return [
    'type' => 'object',
    'properties' => [
        'url' => ['type' => 'string', 'title' => 'URL'],
    ],
    'required' => ['url'],
];

Flat field list — still supported. Several existing nodes (all of AI's, all of Orders') declare fields directly. These keep working and will not be removed — a module installed from the marketplace cannot be rewritten because the platform tightened a contract.

return [
    ['key' => 'url', 'type' => 'text', 'label' => 'URL', 'required' => true],
];

Prefer JSON Schema in new code. Both normalise to the same thing.

Widget selection

For JSON Schema, the control is inferred:

Schema Control
enum present select
type: boolean switch
type: integer / number number input
type: object / array JSON editor
format: email / url / password / textarea / expression that control
anything else text input

An unknown control degrades to a text input rather than rendering nothing — a node should never be silently unconfigurable.

x-ui — presentation hints

Anything the type system cannot express goes in x-ui on the property. It is ignored by JSON Schema validators, so the schema stays valid.

'body' => [
    'type' => 'object',
    'title' => 'Request Body',
    'x-ui' => [
        'widget' => 'json',        // force a control
        'group' => 'Advanced',     // section heading in the panel
        'order' => 10,             // explicit position; default is declaration order
        'placeholder' => '{"key": "value"}',
        'showIf' => ['field' => 'method', 'in' => ['POST', 'PUT', 'PATCH']],
    ],
],
Key Purpose
widget Force a control instead of the inferred one
group Group fields under a heading
order Explicit ordering; otherwise declaration order is kept
placeholder Input placeholder
label Override the label when title is not suitable
enumLabels Map enum values to friendly labels
showIf Conditional visibility
required Mark required without listing it in required (useful with showIf)
patternMessage Message shown when pattern fails

Conditional fields

showIf takes one of three shapes:

'showIf' => ['field' => 'method', 'in' => ['POST', 'PUT']]  // value is one of
'showIf' => ['field' => 'mode', 'equals' => 'advanced']      // value equals
'showIf' => ['field' => 'add_note']                          // value is truthy

Two behaviours matter:

  • A hidden field is not validated. Requiring a value behind a condition the user cannot see would be unfixable from the builder.
  • A hidden field's value is dropped on save. Switching an HTTP node from POST back to GET does not leave a stale body behind.

showIf must name a sibling field in the same node. A dangling reference means the field can never appear, and NodeRegistrySchemaTest fails the build for it.

Validation

POST /workflow-nodes/{identifier}/validate checks the config against your schema before anything is saved:

  • required fields (skipping hidden ones)
  • enum membership
  • minimum / maximum, and minLength / maxLength
  • pattern, with x-ui.patternMessage as the message

false and 0 count as answers, not omissions.

Your node's own validateConfig() runs afterwards for anything a schema cannot express — checking that a credential resolves, say. Return a flat list of messages, or key them by field to have the builder highlight the right input:

public function validateConfig(array $config): array
{
    if (($config['provider'] ?? null) && !$this->providerExists($config['provider'])) {
        return ['provider' => ['That provider is not configured.']];
    }

    return [];
}

The response carries errors (flat, for existing callers) and field_errors (keyed by field, used to highlight inputs).

Simulation (Dry Run)

POST /workflows/{id}/simulate walks a workflow and reports what would happen. It writes nothing — no execution row, no logs, no counters — and it runs only the steps that declare themselves safe.

Not to be confused with POST /workflows/{id}/test, which executes for real under a test label. That one genuinely sends the email.

Your node is stubbed by default

public static function isSimulationSafe(): bool
{
    return false;   // the default, inherited from BaseNode
}

A stubbed node is not executed; its input is passed through so the walk continues and downstream steps are still reported. The default is false on purpose: a node that sends mail, charges a card or calls a paid API must not fire because someone pressed Simulate, and inheriting the safe answer costs nothing.

Opt in only when execute() is pure — reads its input, computes, returns:

/**
 * Reshapes the payload in memory; reaches nothing outside the run.
 */
public static function isSimulationSafe(): bool
{
    return true;
}

When real behaviour is safe but unhelpful

Override simulate(). The Delay node is the clearest case — correct to model, pointless to actually wait for:

public function simulate(array $input, array $config, array $context): array
{
    return array_merge($input, [
        '_delay_seconds' => $this->secondsFrom($config),
        '_simulated_wait' => true,
    ]);
}

$context['simulation'] is true during a dry run, if a node needs to branch on it.

What the report contains

Key Meaning
ok Whether the workflow could run at all
errors Blocking problems — no entry point, a loop, an edge to a missing node
warnings Per-step config problems, prefixed with the step label
steps Ordered trace: index, label, node_type, input, config, mode, reason
unreached Nodes the run would never arrive at — usually an orphan on the canvas
variables Workflow variables as they stood at the end
executed / stubbed How many steps ran versus were skipped

Each step's mode is executed, stubbed or error. Config is validated as part of the walk, so a required field left blank surfaces before the workflow is turned on rather than on a queue worker hours later.

Checklist for a new node

  • Field keys are unique within the node
  • Every field has a label, or a key that humanises well (order_id → "Order Id")
  • Every select has options — an empty dropdown cannot be completed, so the normaliser degrades it to a text input
  • Keys in getDefaultConfig() correspond to real fields
  • Any showIf names a field that exists

NodeRegistrySchemaTest asserts all of these against the live registry, so a new node in any module is covered as soon as it is registered.

Testing Your Nodes

Unit Test

<?php

namespace Modules\Orders\Tests\Unit\Nodes;

use Tests\TestCase;
use Modules\Orders\Nodes\Actions\UpdateOrderStatusAction;
use Modules\Orders\App\Models\Order;

class UpdateOrderStatusActionTest extends TestCase
{
    public function test_updates_order_status()
    {
        $order = Order::factory()->create(['status' => 'pending']);

        $node = new UpdateOrderStatusAction();

        $result = $node->execute(
            ['order' => ['id' => $order->id]],
            ['orderId' => '{{order.id}}', 'newStatus' => 'processing'],
            []
        );

        $this->assertEquals('processing', $order->fresh()->status);
        $this->assertTrue($result['_order_updated']);
        $this->assertEquals('pending', $result['_old_status']);
        $this->assertEquals('processing', $result['_new_status']);
    }

    public function test_validates_required_config()
    {
        $node = new UpdateOrderStatusAction();

        $errors = $node->validateConfig([]);

        $this->assertContains('Order ID is required', $errors);
        $this->assertContains('New status is required', $errors);
    }
}

Integration Test

public function test_workflow_with_custom_node()
{
    $workflow = Workflow::factory()
        ->withNodes([
            ['node_type' => 'orders:order-created'],
            ['node_type' => 'orders:update-order-status', 'config' => [
                'orderId' => '{{order.id}}',
                'newStatus' => 'processing',
            ]],
        ])
        ->create();

    $order = Order::factory()->create();

    $engine = app(ExecutionEngine::class);
    $execution = $engine->execute($workflow, ['order_id' => $order->id]);

    $this->assertEquals('completed', $execution->status);
    $this->assertEquals('processing', $order->fresh()->status);
}

Best Practices

Naming Conventions

  • Identifier: module-alias:node-name (e.g., orders:order-created)
  • Class name: {Action}Node or {Event}Trigger (e.g., SendEmailNode, OrderCreatedTrigger)
  • File location: Nodes/{Category}/{ClassName}.php

Error Handling

public function execute(array $input, array $config, array $context): array
{
    try {
        // Your logic
    } catch (ModelNotFoundException $e) {
        throw new \Exception("Order not found: {$orderId}");
    } catch (\Exception $e) {
        // Log and rethrow with user-friendly message
        Log::error('Node execution failed', [
            'node' => static::getIdentifier(),
            'error' => $e->getMessage(),
        ]);
        throw new \Exception("Failed to update order: {$e->getMessage()}");
    }
}

Output Conventions

Return what your node produced, and prefix its metadata with an underscore:

return [
    // Prefix metadata with underscore
    '_action_performed' => true,
    '_performed_at' => now()->toISOString(),

    // The data this node produced
    'order' => $updatedOrder,
];

Do not echo $input back out. return array_merge($input, [...]) makes every node's output the sum of everything upstream of it. That payload is stored twice per node in workflow_execution_logs (input_data and output_data) and re-encoded into the run's checkpoint after every step, so the cost is paid once per remaining node rather than once. A single HTTP node returning a large response body attaches that body to the run for the rest of its length.

Some built-in nodes still echo — HttpRequestNode, SendEmailNode, IfElseNode and others — because saved workflows resolve bare {{order.total}}-style expressions against the accumulated payload, and taking that away would break them silently. New nodes should not add to it.

Upstream data is reachable without echoing. The engine merges the execution context into the data your {{expressions}} resolve against:

// In BaseNode-derived nodes:
$data = array_merge($input, $context);
$to = $this->resolveExpression($config['to'], $data);

$context carries trigger_data (the payload the run started with, in full), variables (everything Set Variable has set), and workflow_id / execution_id. Both trigger_data and variables are persisted with the execution and restored when a paused run resumes, so:

  • {{trigger_data.order.email}} resolves at any depth in the graph
  • {{variables.discount_code}} resolves wherever it was set
  • {{order.email}} resolves only if every node in between echoed it forward

Prefer the first two. For data your own node computed, name it in the output and have the next node read it from there.

Joins and _branches

A node with more than one inbound connection receives the branches merged, with later branches winning on key collisions, plus a _branches key holding each inbound payload separately for nodes that need to tell them apart.

_branches is for the joining node only. The engine removes it from that node's output before scheduling successors, the same way it removes _pause_seconds — so do not expect it downstream, and do not copy it into your own output.

One place it does survive is the node's own row in workflow_execution_logs. That row is written from the raw return value, before the engine strips its own keys, so output_data for a join still shows _branches — useful when debugging, and the same has always been true of _pause_seconds. The execution result never carries it, on a run that finishes in one go or one that is resumed and rebuilds the node's output from that log row.

Documentation

Document your nodes in your module's README:

## Workflow Nodes

This module provides the following workflow nodes:

### Triggers
- **Order Created** (`orders:order-created`) - Fires when new order is created
- **Order Status Changed** (`orders:order-status-changed`) - Fires on status change

### Actions
- **Update Order Status** (`orders:update-order-status`) - Change order status

Next Steps