Skip to content

Subflows ​

As workflows grow in complexity, it becomes useful to break them down into smaller, reusable components. Flowcraft supports this through subflows.

A subflow is a standard WorkflowBlueprint that can be executed as a single node within another (parent) workflow. This allows you to encapsulate logic, promote reuse, and keep your main workflow graphs clean and organized.

With the Compiler ​

When you import and await another /** @flow */ function, the compiler automatically creates a subflow relationship for you:

typescript
// math-subflow.ts
/** @flow */
export async function addNumbers(a: number, b: number) {
	const sum = await performAddition(a, b)
	return sum
}

/** @step */
async function performAddition(a: number, b: number) {
	return a + b
}

// parent-workflow.ts
import { addNumbers } from './math-subflow'

/** @flow */
export async function parentWorkflow() {
	const result = await addNumbers(10, 20)
	return result
}

This imperative code compiles to the same subflow structure as the Fluent API example below, with addNumbers becoming a subflow node in the parent workflow.

The subflow Node ​

You can run a subflow using the exported SubflowNode class with the builder API, or by defining a node with uses: 'subflow' in a raw blueprint. The SubflowNode approach is recommended for type safety.

The params for a subflow node are critical:

  • blueprintId: The ID of the WorkflowBlueprint to execute. This blueprint must be available in the FlowRuntime's blueprints registry.
  • inputs (optional): An object mapping keys in the subflow's initial context to keys in the parent workflow's context. This is how you pass data into the subflow.
  • outputs (optional): An object mapping keys in the parent workflow's context to keys in the subflow's final context. This is how you get data out of the subflow.

Example: A Reusable Subflow ​

Let's create a subflow that adds two numbers and a parent workflow that uses it.

1. Define the Subflow ​

typescript
// subflow.ts
import { createFlow } from 'flowcraft'

export const mathSubflowBlueprint = createFlow('math-subflow')
	.node('add', async ({ context }) => {
		const a = await context.get('a')
		const b = await context.get('b')
		const sum = a + b
		// The result is stored in the subflow's context.
		return { output: sum }
	})
	.toBlueprint()

2. Define the Parent Workflow ​

Use the exported SubflowNode class to register a subflow node with full type safety:

typescript
// parent-flow.ts
import { createFlow, SubflowNode } from 'flowcraft'

export const parentFlow = createFlow('parent-workflow')
	.node('prepare-data', async ({ context }) => {
		// Set up data in the parent context.
		await context.set('val1', 10)
		await context.set('val2', 20)
		return { output: 'Data ready' }
	})
	.node('run-math', SubflowNode, {
		params: {
			blueprintId: 'math-subflow',
			// Map parent context keys to subflow context keys
			inputs: {
				a: 'val1',
				b: 'val2',
			},
			// Map parent context key to a subflow result key
			outputs: {
				addition_result: 'add', // 'add' is the ID of the node in the subflow
			},
		},
	})
	.edge('prepare-data', 'run-math')

3. Set Up the Runtime ​

The key is to provide all necessary blueprints to the FlowRuntime constructor.

typescript
// main.ts
import { FlowRuntime } from 'flowcraft'
import { parentFlow } from './parent-flow'
import { mathSubflowBlueprint } from './subflow'

const runtime = new FlowRuntime({
	// The runtime needs access to all blueprints it might be asked to run.
	blueprints: {
		'math-subflow': mathSubflowBlueprint,
	},
	// The registry only needs the implementations from the parent flow.
	registry: parentFlow.getFunctionRegistry(),
})

const result = await runtime.run(parentFlow.toBlueprint(), {})
console.log(result.context)
// {
//   val1: 10,
//   val2: 20,
//   prepare_data: 'Data ready',
//   run_math: { a: 10, b: 20, add: 30 }, // Subflow's final context
//   addition_result: 30 // Mapped output
// }

This modular approach is invaluable for building large, maintainable workflow systems.

Error Handling in Subflows ​

When a subflow fails, the error is propagated to the parent workflow with details for better debugging. The FlowcraftError thrown by a failed subflow includes:

  • The original error message from the specific node that failed within the subflow
  • The node ID where the failure occurred
  • The stack trace from the subflow's execution

This allows you to trace failures back to their source, even in deeply nested subflow hierarchies.

typescript
// Example: Handling subflow errors
try {
	const result = await runtime.run(parentFlow.toBlueprint(), {})
} catch (error) {
	if (error instanceof FlowcraftError) {
		console.log(`Subflow failed: ${error.message}`)
		if (error.cause) {
			console.log(`Original error: ${error.cause.message}`)
			console.log(`Failed node in subflow: ${error.cause.nodeId}`)
		}
	}
}

If a subflow fails, it will prevent the parent workflow from continuing unless handled appropriately (e.g., via retries or fallbacks).

Awaiting Subflows ​

Subflows can contain wait nodes, causing the entire parent workflow to pause. When a subflow encounters a wait node, the parent workflow's status becomes 'awaiting', and the subflow's state is persisted in the parent context.

Example: Awaiting Subflow ​

typescript
// subflow-with-wait.ts
import { createFlow } from 'flowcraft'

export const approvalSubflow = createFlow('approval-subflow')
	.node('start', async ({ context }) => {
		await context.set('data', 'Request for approval')
		return { output: 'Started' }
	})
	.edge('start', 'wait-for-approval')
	.wait('wait-for-approval') // Pauses here
	.edge('wait-for-approval', 'process')
	.node('process', async ({ input }) => {
		const approved = input?.approved
		return { output: approved ? 'Approved' : 'Rejected' }
	})
	.toBlueprint()

// parent-flow.ts
import { createFlow, SubflowNode } from 'flowcraft'

export const parentFlow = createFlow('parent-workflow')
	.node('prepare', async ({ context }) => {
		await context.set('request', 'User request')
		return { output: 'Prepared' }
	})
	.edge('prepare', 'subflow-node')
	.node('subflow-node', SubflowNode, {
		params: {
			blueprintId: 'approval-subflow',
			inputs: { data: 'request' },
		},
	})
	.edge('subflow-node', 'finish')
	.node('finish', async ({ context }) => {
		const subflowResult = await context.get('_outputs.subflow-node')
		return { output: `Final result: ${subflowResult}` }
	})

// Execution
const runtime = new FlowRuntime({
	blueprints: { 'approval-subflow': approvalSubflow },
	registry: parentFlow.getFunctionRegistry(),
})

const initialResult = await runtime.run(parentFlow.toBlueprint(), {})
// initialResult.status === 'awaiting'

const resumeResult = await runtime.resume(
	parentFlow.toBlueprint(),
	initialResult.serializedContext,
	{
		output: { approved: true },
	},
)
// resumeResult.status === 'completed'

When resuming, the subflow's state is restored, and execution continues from the wait node.

Released under the MIT License