Subflows & composition
A subflow is a full flowchart mounted inside a parent. Its stages appear in the parent’s narrative and snapshot, and can be drilled into independently.
This is how teams scale with footprintjs: build a library of reusable pipelines (payment processing, inventory checks, risk scoring), own them separately, version them independently, and compose them into parent workflows. The parent sees the full execution tree including every subflow’s internal stages — no black boxes.
Mounting a subflow
Section titled “Mounting a subflow”.addSubFlowChartNext(id, chart, name, options?) mounts a flowchart sequentially. The id is used for snapshot navigation.
import { flowChart, FlowChartBuilder, FlowChartExecutor } from 'footprintjs';
// Build the subflowconst paymentSubflow = new FlowChartBuilder() .start('ValidateCard', async (scope: any) => { scope.cardValid = scope.cardLast4?.length === 4; }, 'validate-card', 'Verify card details') .addFunction('ChargeCard', async (scope: any) => { scope.txnId = 'TXN-' + Date.now(); }, 'charge-card', 'Process the charge') .build();
interface OrderState { orderId: string; amount: number; cardLast4: string; shipped?: boolean;}
// Mount it in the parentconst chart = flowChart<OrderState>('ReceiveOrder', async (scope) => { scope.orderId = 'ORD-100'; scope.amount = 249.99; scope.cardLast4 = '1234';}, 'receive-order') .addSubFlowChartNext('sf-payment', paymentSubflow, 'Payment', { inputMapper: (s: any) => ({ amount: s.amount, cardLast4: s.cardLast4 }), }) .addFunction('ShipOrder', async (scope) => { scope.shipped = true; }, 'ship-order') .build();
const executor = new FlowChartExecutor(chart);executor.enableNarrative();await executor.run();The inputMapper option lets you pass a subset of parent state into the subflow. If omitted, the full parent state is passed.
The matching outputMapper option maps the subflow’s final state back into the parent scope. Top-level array keys are concatenated by default ([...parent, ...subflow]) — return only the delta items, or set arrayMerge: ArrayMergeMode.Replace (from footprintjs/advanced) to overwrite instead. Scalars are always replaced.
.addSubFlowChartNext('sf-payment', paymentSubflow, 'Payment', { inputMapper: (s: any) => ({ amount: s.amount, cardLast4: s.cardLast4 }), outputMapper: (sub: any) => ({ txnId: sub.txnId }),})Mounting multiple subflows
Section titled “Mounting multiple subflows”Mount as many subflows as needed. They run sequentially:
const chart = flowChart<OrderState>('ReceiveOrder', async (scope) => { scope.orderId = 'ORD-100'; scope.amount = 249.99; scope.cardLast4 = '1234';}, 'receive-order') .addSubFlowChartNext('sf-payment', paymentSubflow, 'Payment', { inputMapper: (s: any) => ({ amount: s.amount, cardLast4: s.cardLast4 }), }) .addSubFlowChartNext('sf-inventory', inventorySubflow, 'Inventory') .addFunction('ShipOrder', async (scope) => { scope.shipped = true; }, 'ship-order') .build();Snapshot drill-down
Section titled “Snapshot drill-down”After execution, getSubtreeSnapshot(snapshot, subflowId) extracts the full execution tree for any subflow:
import { flowChart, FlowChartExecutor, getSubtreeSnapshot } from 'footprintjs';
const payment = flowChart('ValidateCard', () => {}, 'validate-card') .addFunction('ChargeCard', () => {}, 'charge-card') .build();const chart = flowChart('ReceiveOrder', () => {}, 'receive-order') .addSubFlowChartNext('sf-payment', payment, 'Payment') .build();
const executor = new FlowChartExecutor(chart);await executor.run();
const snapshot = executor.getSnapshot();const paymentSubtree = getSubtreeSnapshot(snapshot, 'sf-payment');
if (paymentSubtree) { console.log(`Subflow ID: ${paymentSubtree.subflowId}`); console.log(`Root stage: ${paymentSubtree.executionTree.name}`);}listSubflowPaths(snapshot) returns all mounted subflow IDs at any nesting depth:
import { flowChart, FlowChartExecutor, listSubflowPaths } from 'footprintjs';
const payment = flowChart('ChargeCard', () => {}, 'charge-card').build();const inventory = flowChart('ReserveStock', () => {}, 'reserve-stock').build();const chart = flowChart('ReceiveOrder', () => {}, 'receive-order') .addSubFlowChartNext('sf-payment', payment, 'Payment') .addSubFlowChartNext('sf-inventory', inventory, 'Inventory') .build();const executor = new FlowChartExecutor(chart);await executor.run();const snapshot = executor.getSnapshot();
const paths = listSubflowPaths(snapshot);// ['sf-payment', 'sf-inventory']// A nested mount would have a path such as 'sf-outer/sf-inner'.ManifestFlowRecorder
Section titled “ManifestFlowRecorder”ManifestFlowRecorder builds a lightweight tree catalog of all subflows as a side effect of traversal — no post-processing. An LLM receiving a snapshot can read the manifest to understand structure, then pull full specs on demand.
import { flowChart, FlowChartExecutor, ManifestFlowRecorder, type ManifestEntry } from 'footprintjs';
const payment = flowChart('ChargeCard', () => {}, 'charge-card').build();const inventory = flowChart('ReserveStock', () => {}, 'reserve-stock').build();const chart = flowChart('ReceiveOrder', () => {}, 'receive-order') .addSubFlowChartNext('sf-payment', payment, 'Payment') .addSubFlowChartNext('sf-inventory', inventory, 'Inventory') .build();
const executor = new FlowChartExecutor(chart);const manifest = new ManifestFlowRecorder();executor.attachFlowRecorder(manifest);
await executor.run();
function printManifest(entries: ManifestEntry[], indent = 0) { for (const entry of entries) { const pad = ' '.repeat(indent); const desc = entry.description ? ` -- ${entry.description}` : ''; console.log(`${pad}[${entry.subflowId}] ${entry.name}${desc}`); if (entry.children.length > 0) printManifest(entry.children, indent + 1); }}
printManifest(manifest.getManifest());// Prints each mounted subflow's id, name, and any description.ComposableRunner pattern
Section titled “ComposableRunner pattern”When a service owns its own internal flowchart, implement ComposableRunner so a parent can mount it as a subflow:
import { FlowChartBuilder, FlowChartExecutor, getSubtreeSnapshot, type ComposableRunner, type FlowChart, type RunOptions, type TypedScope,} from 'footprintjs';
interface PaymentState { amount: number; cardValid?: boolean; txnId?: string;}
class PaymentProcessor implements ComposableRunner<{ amount: number }, { txnId: string }> { private chart: FlowChart;
constructor() { this.chart = new FlowChartBuilder<any, TypedScope<PaymentState>>() .start('ValidateCard', (scope) => { scope.cardValid = scope.amount > 0 && scope.amount < 10_000; }, 'validate-card', 'Verify card details and limits') .addFunction('ChargeCard', (scope) => { scope.txnId = `TXN-${Date.now()}`; }, 'charge-card', 'Process the charge') .build(); }
toFlowChart(): FlowChart { return this.chart; }
async run(input: { amount: number }, options?: RunOptions): Promise<{ txnId: string }> { const executor = new FlowChartExecutor(this.chart); await executor.run({ input, ...options }); const snap = executor.getSnapshot(); return { txnId: (snap?.sharedState?.txnId as string) ?? 'unknown' }; }}Mount it in a parent flowchart using toFlowChart(). This helper accepts an application-supplied payment runner, such as new PaymentProcessor() from the example above:
import { flowChart, FlowChartExecutor, getSubtreeSnapshot, type ComposableRunner } from 'footprintjs';
export async function runOrder(payment: ComposableRunner<{ amount: number }, { txnId: string }>) { const orderChart = flowChart<{ orderId: string; amount: number }>('ReceiveOrder', (scope) => { scope.orderId = 'ORD-42'; scope.amount = 149.99; }, 'receive-order') .addSubFlowChartNext('sf-payment', payment.toFlowChart(), 'Payment', { inputMapper: (s: { orderId: string; amount: number }) => ({ amount: s.amount }), }) .build();
const executor = new FlowChartExecutor(orderChart); await executor.run();
// The supplied runner's stages are available in this subtree. return getSubtreeSnapshot(executor.getSnapshot(), 'sf-payment');}Try it live
Section titled “Try it live”- Subflow — nested payment flowchart
- Lazy Subflow — graph-of-services with conditional resolution