Skip to main content

Workflow Data Schema

Workflows are stored as JSON in the workflowData field of the Workflow model. This document describes the complete schema specification.

Overview

The workflow data structure uses a normalized format that separates node definitions, connectors, and flow structure:

  • Nodes — Each node defined once in a flat collection
  • Connectors — Link/edge data with full presentation (routing points)
  • Workflow — Tree structure using references, not inline data
  • Presentation — Canvas viewport state

This design enables:

  • Node reuse across multiple branches
  • Preservation of connector routing/layout
  • Clean separation of flow logic from presentation
  • Efficient updates without data duplication

Top-Level Structure

interface WorkflowData {
nodes: Record<string, WorkflowNodeDef>; // Node definitions by ID
connectors: Record<string, WorkflowConnector>; // Connector definitions by ID
workflow: WorkflowRef; // Flow structure with refs
presentation?: {
viewport: {
x: number; // Canvas pan X offset
y: number; // Canvas pan Y offset
zoom: number; // Zoom level (100 = 100%)
};
};
}
FieldTypeDescription
nodesRecord<string, WorkflowNodeDef>Map of node ID → node definition
connectorsRecord<string, WorkflowConnector>Map of connector ID → connector definition
workflowWorkflowRefRoot of the workflow tree (using refs)
presentationobjectCanvas viewport state for UI restoration

Node Definition (WorkflowNodeDef)

interface WorkflowNodeDef {
entityId?: string; // UUID of the WorkflowEntity
name: string; // Display name
entityType: string; // 'event' | 'prompt' | 'action' | 'result' | 'logic-branch'
presentation: {
type: string; // Node shape: 'circle', 'star', 'octagon', 'brain'
position: {
x: number; // X coordinate on canvas
y: number; // Y coordinate on canvas
};
};

// Event-specific fields
tfCondition?: string; // 'Single Path' | 'True/False' | 'Multi' | 'Iterable'
logicField?: string; // Variable name for Multi/Iterable mode

// Logic branch fields
branchValue?: string; // Branch value (e.g., "true", "false", "admin")
}

Node IDs

Node IDs are generated during serialization (e.g., n1, n2, n3). They're internal references, not persisted entity IDs.

Connector Definition (WorkflowConnector)

interface WorkflowConnector {
source: string; // Source node ID (e.g., "n1")
target: string; // Target node ID (e.g., "n2")
sourcePort: string; // Port name: 'out', 'true', 'false'
targetPort: string; // Port name: 'in'
presentation?: {
points?: Array<{ // Routing points for curved/segmented lines
x: number;
y: number;
}>;
};
}

Connector Features

  • Full routing preservation — Points array stores exact line path
  • Port identification — Source/target ports enable branch routing
  • Layout persistence — User-adjusted link paths are saved

Workflow Reference (WorkflowRef)

interface WorkflowRef {
ref: string; // Node ID reference (e.g., "n1")
value?: string; // Branch value (for logic branches)
children: WorkflowRef[]; // Child references
}

The workflow field contains a tree of references, not inline node data:

{
"ref": "n1",
"children": [
{ "ref": "n2", "children": [] }
]
}

Complete Example

A workflow that checks if a user is premium and routes accordingly:

{
"nodes": {
"n1": {
"entityId": "550e8400-e29b-41d4-a716-446655440001",
"name": "Check Premium Status",
"entityType": "event",
"tfCondition": "True/False",
"presentation": {
"type": "circle",
"position": { "x": 100, "y": 200 }
}
},
"n2": {
"name": "true",
"entityType": "logic-branch",
"branchValue": "true",
"presentation": {
"type": "logic-branch",
"position": { "x": 300, "y": 100 }
}
},
"n3": {
"name": "false",
"entityType": "logic-branch",
"branchValue": "false",
"presentation": {
"type": "logic-branch",
"position": { "x": 300, "y": 300 }
}
},
"n4": {
"entityId": "550e8400-e29b-41d4-a716-446655440010",
"name": "Generate Premium Welcome",
"entityType": "prompt",
"presentation": {
"type": "brain",
"position": { "x": 500, "y": 100 }
}
},
"n5": {
"entityId": "550e8400-e29b-41d4-a716-446655440020",
"name": "Log Basic User",
"entityType": "action",
"presentation": {
"type": "star",
"position": { "x": 500, "y": 300 }
}
},
"n6": {
"entityId": "550e8400-e29b-41d4-a716-446655440030",
"name": "Success Result",
"entityType": "result",
"presentation": {
"type": "octagon",
"position": { "x": 700, "y": 100 }
}
},
"n7": {
"entityId": "550e8400-e29b-41d4-a716-446655440031",
"name": "Basic Success",
"entityType": "result",
"presentation": {
"type": "octagon",
"position": { "x": 700, "y": 300 }
}
}
},
"connectors": {
"c1": {
"source": "n1",
"target": "n2",
"sourcePort": "out",
"targetPort": "in",
"presentation": {
"points": [
{ "x": 160, "y": 200 },
{ "x": 230, "y": 200 },
{ "x": 230, "y": 100 },
{ "x": 300, "y": 100 }
]
}
},
"c2": {
"source": "n1",
"target": "n3",
"sourcePort": "out",
"targetPort": "in",
"presentation": {
"points": [
{ "x": 160, "y": 200 },
{ "x": 230, "y": 200 },
{ "x": 230, "y": 300 },
{ "x": 300, "y": 300 }
]
}
},
"c3": {
"source": "n2",
"target": "n4",
"sourcePort": "out",
"targetPort": "in"
},
"c4": {
"source": "n3",
"target": "n5",
"sourcePort": "out",
"targetPort": "in"
},
"c5": {
"source": "n4",
"target": "n6",
"sourcePort": "out",
"targetPort": "in"
},
"c6": {
"source": "n5",
"target": "n7",
"sourcePort": "out",
"targetPort": "in"
}
},
"workflow": {
"ref": "n1",
"children": [
{
"ref": "n2",
"value": "true",
"children": [
{
"ref": "n4",
"children": [
{ "ref": "n6", "children": [] }
]
}
]
},
{
"ref": "n3",
"value": "false",
"children": [
{
"ref": "n5",
"children": [
{ "ref": "n7", "children": [] }
]
}
]
}
]
},
"presentation": {
"viewport": {
"x": 50,
"y": 100,
"zoom": 100
}
}
}

Entity Types

Event Node

Entry point with condition evaluation:

{
"n1": {
"entityId": "uuid",
"name": "Check User Type",
"entityType": "event",
"tfCondition": "Single Path",
"presentation": {
"type": "circle",
"position": { "x": 100, "y": 200 }
}
}
}

Event with True/False Branching

{
"n1": {
"entityId": "uuid",
"name": "Is Premium?",
"entityType": "event",
"tfCondition": "True/False",
"presentation": { "type": "circle", "position": { "x": 100, "y": 200 } }
},
"n2": {
"name": "true",
"entityType": "logic-branch",
"branchValue": "true",
"presentation": { "type": "logic-branch", "position": { "x": 300, "y": 100 } }
},
"n3": {
"name": "false",
"entityType": "logic-branch",
"branchValue": "false",
"presentation": { "type": "logic-branch", "position": { "x": 300, "y": 300 } }
}
}

Event with Multi Branching

{
"n1": {
"entityId": "uuid",
"name": "Route by Role",
"entityType": "event",
"tfCondition": "Multi",
"logicField": "userRole",
"presentation": { "type": "circle", "position": { "x": 100, "y": 200 } }
},
"n2": {
"name": "Admin",
"entityType": "logic-branch",
"branchValue": "admin",
"presentation": { "type": "logic-branch", "position": { "x": 300, "y": 100 } }
},
"n3": {
"name": "User",
"entityType": "logic-branch",
"branchValue": "user",
"presentation": { "type": "logic-branch", "position": { "x": 300, "y": 250 } }
},
"n4": {
"name": "Guest",
"entityType": "logic-branch",
"branchValue": "guest",
"presentation": { "type": "logic-branch", "position": { "x": 300, "y": 400 } }
}
}

Event with Iterable Mode

Iterable mode allows the workflow to repeat for each item in an array. This is useful for processing lists of items like batch operations, multiple recipients, or array data.

{
"n1": {
"entityId": "uuid",
"name": "Process Each Recipient",
"entityType": "event",
"tfCondition": "Iterable",
"logicField": "message.recipients",
"presentation": { "type": "circle", "position": { "x": 100, "y": 200 } }
},
"n2": {
"entityId": "uuid",
"name": "Send Notification",
"entityType": "action",
"presentation": { "type": "star", "position": { "x": 350, "y": 200 } }
}
}

How Iterable Mode Works:

  1. The logicField points to an array in the message (e.g., message.recipients)
  2. The workflow executes its children once for each item in the array
  3. Each iteration receives the current item as ___iterableItem___ in the context
  4. If the field is not an array, the workflow proceeds normally (single execution)

Prompt Node

{
"n5": {
"entityId": "uuid",
"name": "Generate Welcome",
"entityType": "prompt",
"presentation": {
"type": "brain",
"position": { "x": 500, "y": 200 }
}
}
}

Action Node

{
"n6": {
"entityId": "uuid",
"name": "Post to Mastodon",
"entityType": "action",
"presentation": {
"type": "star",
"position": { "x": 700, "y": 200 }
}
}
}

Result Node

{
"n7": {
"entityId": "uuid",
"name": "Success",
"entityType": "result",
"presentation": {
"type": "octagon",
"position": { "x": 900, "y": 200 }
}
}
}

Data Flow

flowchart TB
subgraph Admin[Admin Project]
Canvas[React Diagrams Canvas]
Serialize[serializeWorkflow]
Deserialize[deserializeWorkflow]
API[Workflow API]
end

subgraph DB[Database]
WorkflowTable[Workflow.workflowData JSON]
end

subgraph Consumer[Consumer Project]
Cache[WorkflowCache]
Evaluator[Evaluator.traverseNode]
NodeResolver[resolveNodeRef]
end

Canvas --> Serialize
Serialize --> API
API --> WorkflowTable
WorkflowTable --> Cache
Cache --> Evaluator
Evaluator --> NodeResolver
NodeResolver --> Evaluator

Deserialize --> Canvas
API --> Deserialize

Serialization (Canvas → JSON)

The Admin app's serializeWorkflow() function:

  1. Build nodes map — Extract all diagram nodes with generated IDs (n1, n2, ...)
  2. Build connectors map — Extract all diagram links with generated IDs (c1, c2, ...)
  3. Build workflow tree — Derive tree structure from connector relationships using refs
  4. Handle logic branches — Add value property on branch refs
// Pseudocode
function serializeWorkflow(): WorkflowData {
const nodes: Record<string, WorkflowNodeDef> = {};
const connectors: Record<string, WorkflowConnector> = {};
const nodeIdMap = new Map<DiagramNode, string>();

// 1. Build nodes map
let nodeCounter = 1;
for (const diagramNode of model.getNodes()) {
const nodeId = `n${nodeCounter++}`;
nodeIdMap.set(diagramNode, nodeId);
nodes[nodeId] = {
entityId: diagramNode.entityId,
name: diagramNode.name,
entityType: diagramNode.entityType,
presentation: {
type: diagramNode.type,
position: diagramNode.getPosition()
},
// ... other fields
};
}

// 2. Build connectors map
let connectorCounter = 1;
for (const link of model.getLinks()) {
const connectorId = `c${connectorCounter++}`;
connectors[connectorId] = {
source: nodeIdMap.get(link.getSourceNode()),
target: nodeIdMap.get(link.getTargetNode()),
sourcePort: link.getSourcePort().getName(),
targetPort: link.getTargetPort().getName(),
presentation: {
points: link.getPoints().map(p => ({ x: p.getX(), y: p.getY() }))
}
};
}

// 3. Build workflow tree from connectors
const workflow = buildWorkflowTree(nodes, connectors);

return { nodes, connectors, workflow, presentation: { viewport: {...} } };
}

Deserialization (JSON → Canvas)

The Admin app's deserializeWorkflow() function:

  1. Create diagram nodes — Instantiate from nodes collection
  2. Position nodes — Apply presentation.position coordinates
  3. Create diagram links — Instantiate from connectors collection
  4. Restore routing — Apply connector presentation.points for exact layout
  5. Validate structure — Use workflow tree for orphan detection
// Pseudocode
function deserializeWorkflow(data: WorkflowData): void {
const nodeMap = new Map<string, DiagramNode>();

// 1. Create nodes
for (const [nodeId, nodeDef] of Object.entries(data.nodes)) {
const node = createNodeByType(nodeDef.entityType, nodeDef);
node.setPosition(nodeDef.presentation.position.x, nodeDef.presentation.position.y);
model.addNode(node);
nodeMap.set(nodeId, node);
}

// 2. Create connectors with routing
for (const [connectorId, connector] of Object.entries(data.connectors)) {
const sourceNode = nodeMap.get(connector.source);
const targetNode = nodeMap.get(connector.target);

const link = createLink(sourceNode, targetNode, connector.sourcePort, connector.targetPort);

// Restore routing points
if (connector.presentation?.points) {
link.setPoints(connector.presentation.points);
}

model.addLink(link);
}

// 3. Restore viewport
if (data.presentation?.viewport) {
model.setOffset(data.presentation.viewport.x, data.presentation.viewport.y);
model.setZoomLevel(data.presentation.viewport.zoom);
}
}

Consumer Execution

The Consumer's Evaluator.traverseNode() resolves refs at runtime:

// In Evaluator.js
async traverseNode(nodeRef, context, workflowMetadata) {
// Resolve ref to node definition
const nodeDef = this.nodesMap[nodeRef.ref];
if (!nodeDef || !nodeDef.entityId) {
return false;
}

// Get entity from cache
const entity = this.getEntity(nodeDef.entityId);

// Evaluate with appropriate evaluator
const result = await evaluator.evaluate(context, entity, nodeRef, ...);

// Process children refs
if (result.children) {
for (const childRef of result.children) {
await this.traverseNode(childRef, context, workflowMetadata);
}
}
}

Branch Matching

For True/False and Multi mode events, the value property on workflow refs determines branch selection:

// In EventEvaluator.js
findMatchingBranch(nodeRef, branchValue) {
// Children have 'value' property from workflow structure
return nodeRef.children.find(child =>
String(child.value) === String(branchValue)
);
}

Schema Validation

The Consumer validates workflows before execution:

CheckBehavior on Failure
Missing workflowSkip workflow (log warning)
Missing ref in workflowSkip node (log warning)
ref not found in nodesSkip node (log warning)
entityId not in cacheSkip node (log warning)
Circular referenceDetected via visited set, skip

Database Storage

model Workflow {
id String @id @default(uuid())
organizationId String
environmentId String
name String
description String?
workflowData Json? // The JSON structure documented here
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

PostgreSQL's JSONB type enables:

  • Efficient storage with compression
  • Indexing on JSON paths (if needed)
  • Querying workflows containing specific entity IDs

Benefits of This Format

BenefitDescription
No duplicationEach node defined once, referenced multiple times
Full layout preservationConnector routing points stored
Clean separationFlow logic separate from presentation
Easier updatesChange node once, reflected everywhere
Simpler debuggingFlat structures easier to inspect