Skip to content

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.

.addSubFlowChartNext(id, chart, name, options?) mounts a flowchart sequentially. The id is used for snapshot navigation.

import { flowChart, FlowChartBuilder, FlowChartExecutor } from 'footprintjs';
// Build the subflow
const 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 parent
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 }),
})
.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 }),
})

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();

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 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.

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');
}
  • Subflow — nested payment flowchart
  • Lazy Subflow — graph-of-services with conditional resolution