Skip to main content
Nodes are the fundamental building blocks of CrystalFlow workflows. Each node represents a single operation or task in your workflow.

What is a Node?

A Node is a TypeScript class that:
  • Extends the base Node class
  • Is decorated with @defineNode to provide metadata
  • Has inputs (data it receives) decorated with @Input
  • Has outputs (data it produces) decorated with @Output
  • Contains business logic in the execute() method

Anatomy of a Node

import { Node, defineNode, Input, Output } from '@crystalflow/core';

@defineNode({
  type: 'math.add',           // Unique identifier
  label: 'Add Numbers',       // Display name
  category: 'Math',           // Category for organization
  description: 'Adds two numbers together'  // Optional description
})
class AddNode extends Node {
  // Inputs - data the node receives
  @Input({ type: 'number', label: 'A', defaultValue: 0 })
  a: number = 0;

  @Input({ type: 'number', label: 'B', defaultValue: 0 })
  b: number = 0;

  // Outputs - data the node produces
  @Output({ type: 'number', label: 'Result' })
  result: number;

  // Business logic
  execute() {
    this.result = this.a + this.b;
  }
}

Node Metadata

The @defineNode decorator provides metadata about the node:
type
string
required
Unique identifier for the node type (e.g., 'math.add', 'http.request')
label
string
required
Human-readable name displayed in the UI
category
string
required
Category for organizing nodes in the palette
description
string
Optional description of what the node does

Inputs

Inputs are defined using the @Input decorator:
@Input({
  type: 'string',              // Data type
  label: 'Message',            // Display label
  defaultValue: 'Hello',       // Default value
  required: false,             // Is this input required?
  description: 'Input message' // Optional description
})
message: string = 'Hello';

Input Options

type
string
required
The data type: 'string', 'number', 'boolean', 'any', or custom types
label
string
required
Display name for the input port
defaultValue
any
Default value if no connection is made
required
boolean
default:"false"
Whether this input must be connected
description
string
Description of the input’s purpose

Outputs

Outputs are defined using the @Output decorator:
@Output({
  type: 'string',              // Data type
  label: 'Result',             // Display label
  description: 'The result'    // Optional description
})
result: string;

Output Options

type
string
required
The data type being output
label
string
required
Display name for the output port
description
string
Description of the output

Conditional Nodes

Conditional nodes enable branching logic in workflows by implementing the IConditionalNode interface:
import { Node, defineNode, Input, Output, IConditionalNode } from '@crystalflow/core';

@defineNode({
  type: 'flow.if',
  label: 'If',
  category: 'Flow Control',
})
class IfNode extends Node implements IConditionalNode {
  @Input({ type: 'boolean', label: 'Condition', required: true })
  condition!: boolean;

  @Output({ type: 'any', label: 'Then' })
  thenOutput?: any;

  @Output({ type: 'any', label: 'Else' })
  elseOutput?: any;

  execute() {
    if (this.condition) {
      this.thenOutput = this.value;
    } else {
      this.elseOutput = this.value;
    }
  }

  evaluateCondition(): string {
    return this.condition ? 'thenOutput' : 'elseOutput';
  }
}

The IConditionalNode Interface

interface IConditionalNode {
  evaluateCondition(): string;
}
Conditional nodes must implement evaluateCondition() which returns the name of the output port that should execute. The execution engine uses this to determine which branch to follow.
The return value of evaluateCondition() must match an output port name. For example, if a node has outputs ‘thenOutput’ and ‘elseOutput’, the method should return one of those strings.

How Branches Work

  1. The conditional node executes normally (via execute() method)
  2. The execution engine calls evaluateCondition() to get the active branch
  3. Only nodes connected to the active output port execute
  4. Other branches are skipped entirely

Dynamic Outputs

Some conditional nodes generate outputs dynamically based on configuration:
@defineNode({
  type: 'flow.switch',
  label: 'Switch',
  category: 'Flow Control',
})
class SwitchNode extends Node implements IConditionalNode {
  @Property({ type: 'array', label: 'Cases', defaultValue: [] })
  cases: any[] = [];

  // Dynamically generate outputs based on cases
  getOutputs(): PortDefinition[] {
    const outputs = [];
    for (let i = 0; i < this.cases.length; i++) {
      outputs.push({ id: `case_${i}`, label: String(this.cases[i]), type: 'any' });
    }
    outputs.push({ id: 'default', label: 'Default', type: 'any' });
    return outputs;
  }

  evaluateCondition(): string {
    const index = this.cases.indexOf(this.value);
    return index !== -1 ? `case_${index}` : 'default';
  }
}

Built-in Conditionals

CrystalFlow includes IfNode and SwitchNode for common branching patterns

Custom Conditionals

Implement IConditionalNode to create custom branching logic
For more details, see the Conditional Flow guide and Conditional Logic examples.

Properties

Properties are static configuration values (not connected to other nodes):
@Property({
  type: 'select',
  label: 'Operation',
  defaultValue: 'add',
  options: [
    { value: 'add', label: 'Add' },
    { value: 'subtract', label: 'Subtract' }
  ]
})
operation: string = 'add';
  • @Property: Static configuration shown in properties panel (not connected)
  • @Input: Dynamic data flow connections via handles
  • @Output: Computed results generated during execution

The execute() Method

The execute() method contains the node’s business logic:
execute() {
  // Read inputs
  const inputA = this.a;
  const inputB = this.b;
  
  // Perform operations
  const sum = inputA + inputB;
  
  // Set outputs
  this.result = sum;
}

Execution Rules

Synchronous or Async - execute() can be sync or async
Read from inputs - Access input values as instance properties
Write to outputs - Set output values before method returns
No side effects - Avoid modifying external state when possible

Async Execution

async execute() {
  const response = await fetch(this.url);
  const data = await response.json();
  this.data = data;
}

Node Lifecycle

  1. Validation - Inputs are validated before execution
  2. Execution - The execute() method runs
  3. State Update - Node state is updated (Success/Error)
  4. Output Propagation - Outputs are passed to connected nodes

Node States

Idle

Initial state, not yet executed

Success

Executed successfully

Error

Execution failed with an error

Type Safety

CrystalFlow uses TypeScript for type safety:
// Type-safe inputs
@Input({ type: 'number', label: 'Count' })
count: number = 0;

// Type-safe outputs
@Output({ type: 'string', label: 'Message' })
message: string;

execute() {
  // TypeScript ensures type correctness
  this.message = `Count is ${this.count}`;
}

Node Categories

Organize your nodes into logical categories:

Input

Data sources (user input, files, APIs)

Processing

Transform and manipulate data

Output

Display results, save files, send data

Flow Control

Conditional branching and decision making

Logic

Boolean operations and comparisons

Math

Mathematical operations

String

String manipulation

Data

Data transformation and filtering

Example Nodes

Data Processing Node

@defineNode({
  type: 'data.filter',
  label: 'Filter Array',
  category: 'Data',
})
class FilterNode extends Node {
  @Input({ type: 'any[]', label: 'Array' })
  array: any[] = [];

  @Property({ type: 'string', label: 'Condition' })
  condition: string = '';

  @Output({ type: 'any[]', label: 'Filtered' })
  filtered: any[];

  execute() {
    this.filtered = this.array.filter(item => {
      // Evaluate condition
      return eval(this.condition);
    });
  }
}

HTTP Request Node

@defineNode({
  type: 'http.request',
  label: 'HTTP Request',
  category: 'Network',
})
class HttpRequestNode extends Node {
  @Property({ type: 'string', label: 'URL', required: true })
  url: string = '';

  @Property({
    type: 'select',
    label: 'Method',
    options: [
      { value: 'GET', label: 'GET' },
      { value: 'POST', label: 'POST' }
    ]
  })
  method: string = 'GET';

  @Output({ type: 'any', label: 'Response' })
  response: any;

  async execute() {
    const res = await fetch(this.url, { method: this.method });
    this.response = await res.json();
  }
}

Best Practices

Each node should do one thing well. Split complex logic into multiple nodes.
Check input values in execute() and throw descriptive errors for invalid data.
Use clear, descriptive names for types, labels, and variables.
Add description fields to explain what inputs, outputs, and properties do.
Use try-catch blocks and throw meaningful errors.

Next Steps