| name | footprint |
| description | Use when building flowchart pipelines with footprintjs — stage functions, decider branches, selectors, subflows, loops, narrative traces, recorders, redaction, contracts, and LLM-ready output. Also use when someone asks how footprint.js works or wants to understand the library. |
footprint.js — The Flowchart Pattern for Backend Code
footprint.js structures backend logic as a graph of named functions with transactional state. The code becomes self-explainable: every run auto-generates a causal trace of what happened and why.
Core principle: All data collection happens during the single DFS traversal pass — never post-process or walk the tree again.
npm install footprintjs
Quick Start
import { flowChart, FlowChartExecutor } from 'footprintjs';
interface OrderState {
orderId: string;
amount: number;
paymentStatus: string;
}
const chart = flowChart<OrderState>('ReceiveOrder', (scope) => {
scope.orderId = 'ORD-123';
scope.amount = 49.99;
}, 'receive-order', { description: 'Receive and validate the incoming order' })
.addFunction('ProcessPayment', (scope) => {
const amount = scope.amount;
scope.paymentStatus = amount < 100 ? 'approved' : 'review';
}, 'process-payment', 'Charge customer and record payment status')
.build();
const executor = new FlowChartExecutor(chart);
await executor.run({ input: { orderId: 'ORD-123' } });
console.log(executor.getNarrativeEntries());
FlowChartBuilder API
Always chain from flowChart<T>() (recommended) or flowChart().
Linear Stages
import { flowChart } from 'footprintjs';
interface MyState {
valueA: string;
valueB: number;
valueC: boolean;
}
const chart = flowChart<MyState>('StageA', fnA, 'stage-a', { description: 'Description of A' })
.addFunction('StageB', fnB, 'stage-b', 'Description of B')
.addFunction('StageC', fnC, 'stage-c', 'Description of C')
.build();
Parameters: (name: string, fn: PipelineStageFunction, id: string, description?: string)
name — human-readable label (used in narrative)
fn — the stage function
id — stable identifier (used for branching, visualization, loop targets)
description — optional, appears in narrative and auto-generated tool descriptions
Stage Function Signature (TypedScope)
With flowChart<T>(), stage functions receive a TypedScope<T> proxy. All reads and writes use typed property access:
interface LoanState {
creditTier: string;
amount: number;
customer: { name: string; address: { zip: string } };
tags: string[];
approved?: boolean;
}
const myStage = (scope: TypedScope<LoanState>) => {
scope.creditTier = 'A';
scope.amount = 50000;
scope.customer.address.zip = '90210';
scope.tags.push('vip');
scope.approved = true;
scope.$debug('checkpoint', { step: 1 });
scope.$metric('latency', 42);
const args = scope.$getArgs<{ requestId: string }>();
const env = scope.$getEnv();
scope.$break();
};
Three access tiers:
- Typed properties (
scope.amount = 50000) — mutable shared state, tracked in narrative
$getArgs() — frozen business input from run({ input }), NOT tracked
$getEnv() — frozen infrastructure context from run({ env }), NOT tracked. Returns ExecutionEnv { signal?, timeoutMs?, traceId? }. Auto-inherited by subflows. Closed type.
Decider Branches with decide() (Single-Choice Conditional)
Use decide() for structured decision evidence capture. It auto-records which values led to the decision in the narrative.
import { decide } from 'footprintjs';
interface RiskState {
creditScore: number;
dti: number;
riskTier: string;
}
const chart = flowChart<RiskState>('Intake', intakeFn, 'intake')
.addDeciderFunction('AssessRisk', (scope) => {
return decide(scope, [
{ when: { creditScore: { gt: 700 }, dti: { lt: 0.43 } }, then: 'low-risk', label: 'Good credit' },
{ when: (s) => s.creditScore > 600, then: 'medium-risk', label: 'Marginal credit' },
], 'high-risk');
}, 'assess-risk', 'Evaluate risk and route accordingly')
.addFunctionBranch('high-risk', 'RejectApplication', rejectFn, 'Reject due to high risk')
.addFunctionBranch('medium-risk', 'ManualReview', reviewFn, 'Send to manual review')
.addFunctionBranch('low-risk', 'ApproveApplication', approveFn, 'Approve the application')
.setDefault('high-risk')
.end()
.build();
The decide() function accepts two when formats:
- Filter format:
{ creditScore: { gt: 700 } } — declarative, auto-captures evidence
- Function format:
(s) => s.creditScore > 600 — arbitrary logic with optional label
The decider function returns a branch ID string. The engine matches it to a child and executes that branch. The decision and its evidence are recorded in the narrative.
Selector Branches with select() (Multi-Choice Fan-Out)
Use select() for structured multi-choice evidence capture:
import { select } from 'footprintjs';
interface CheckState {
needsCredit: boolean;
needsIdentity: boolean;
}
const chart = flowChart<CheckState>('Intake', intakeFn, 'intake')
.addSelectorFunction('SelectChecks', (scope) => {
return select(scope, [
{ when: { needsCredit: { eq: true } }, then: 'credit-check', label: 'Credit required' },
{ when: { needsIdentity: { eq: true } }, then: 'identity-check', label: 'Identity required' },
]);
}, 'select-checks')
.addFunctionBranch('credit-check', 'CreditCheck', creditFn)
.addFunctionBranch('identity-check', 'IdentityCheck', identityFn)
.end()
.build();
Parallel Execution (Fork)
builder.addListOfFunction([
{ id: 'check-a', name: 'CheckA', fn: checkAFn },
{ id: 'check-b', name: 'CheckB', fn: checkBFn },
{ id: 'check-c', name: 'CheckC', fn: checkCFn },
], { failFast: true });
Subflows (Nested Flowcharts)
const creditSubflow = flowChart<CreditState>('PullReport', pullReportFn, 'pull-report')
.addFunction('ScoreReport', scoreReportFn, 'score-report')
.build();
builder.addSubFlowChartNext('credit-sub', creditSubflow, 'CreditCheck', {
inputMapper: (parentScope) => ({ ssn: parentScope.ssn }),
outputMapper: (subOut, parentScope) => ({ creditScore: subOut.score }),
});
builder.addDeciderFunction('Route', routerFn, 'route')
.addSubFlowChartBranch('detailed', creditSubflow, 'DetailedCheck')
.addFunctionBranch('simple', 'SimpleCheck', simpleFn)
.end();
Loops
interface RetryState {
attempts: number;
paymentResult?: string;
}
builder
.addFunction('RetryPayment', async (scope) => {
scope.attempts = (scope.attempts ?? 0) + 1;
if (scope.attempts >= 3) return;
}, 'retry-payment')
.loopTo('retry-payment');
Configuration
builder
.contract({
input: zodSchema,
output: outputZodSchema,
mapper: (state) => ({
decision: state.decision,
reason: state.reason,
}),
});
Output
const chart = builder.build();
const spec = builder.toSpec();
const mermaid = builder.toMermaid();
FlowChartExecutor API
import { FlowChartExecutor } from 'footprintjs';
interface AppState {
applicantName: string;
income: number;
riskTier?: string;
decision?: string;
}
const executor = new FlowChartExecutor(chart);
const result = await executor.run({
input: { applicantName: 'Bob', income: 42000 },
env: { traceId: 'req-123', timeoutMs: 5000 },
});
const narrative: string[] = executor.getNarrativeEntries();
const entries: CombinedNarrativeEntry[] = executor.getNarrativeEntries();
const snapshot = executor.getSnapshot();
const flowOnly: string[] = executor.getNarrativeEntries();
Recorder System — Collect During Traversal
The core innovation. Two observer layers fire during the single DFS pass:
Scope Recorders (data operations)
Fire during typed property access (reads/writes). Attach via executor.attachScopeRecorder():
import { MetricRecorder, DebugRecorder } from 'footprintjs';
const metrics = new MetricRecorder();
const debug = new DebugRecorder('verbose');
executor.attachScopeRecorder(metrics);
executor.attachScopeRecorder(debug);
await executor.run({ input: data });
metrics.getMetrics();
debug.getEntries();
FlowRecorders (control flow events)
Attached to executor, fire after each stage/decision/fork:
import { NarrativeFlowRecorder, AdaptiveNarrativeFlowRecorder } from 'footprintjs';
executor.attachFlowRecorder(new NarrativeFlowRecorder());
Custom FlowRecorder
import type { FlowRecorder, FlowStageEvent, FlowDecisionEvent } from 'footprintjs';
const myRecorder: FlowRecorder = {
id: 'my-recorder',
onStageExecuted(event: FlowStageEvent) {
console.log(`Executed: ${event.stageName}`);
},
onDecision(event: FlowDecisionEvent) {
console.log(`Decision at ${event.decider}: chose ${event.chosen}`);
if (event.evidence) {
console.log(`Evidence: ${JSON.stringify(event.evidence)}`);
}
},
clear() {
},
};
executor.attachFlowRecorder(myRecorder);
CombinedNarrativeRecorder (the inline dual-channel recorder)
This is what powers getNarrativeEntries() and getNarrativeEntries(). It implements BOTH Recorder (scope) and FlowRecorder (engine) interfaces. It buffers scope ops per-stage, then flushes when the flow event arrives — producing merged entries in a single pass.
You don't need to create this manually. Use executor.recorder(narrative()) at runtime to attach it.
Redaction (PII Protection)
executor.setRedactionPolicy({
keys: ['ssn', 'creditCardNumber'],
patterns: [/password/i, /^secret.*/],
fields: { applicant: ['ssn', 'address.zip'] },
});
await executor.run({ input: { ... } });
const report = executor.getRedactionReport();
Contracts & OpenAPI
import { flowChart } from 'footprintjs';
import { z } from 'zod';
const chart = flowChart('ProcessLoan', receiveFn, 'process-loan')
.addFunction('Assess', assessFn, 'assess')
.contract({
input: z.object({
applicantName: z.string(),
income: z.number(),
}),
output: z.object({
decision: z.enum(['approved', 'rejected']),
reason: z.string(),
}),
mapper: (state) => ({
decision: state.decision,
reason: state.reason,
}),
})
.build();
const openApiSpec = chart.toOpenAPI({
title: 'Loan Underwriting API',
version: '1.0.0',
});
Event Ordering (Critical for Understanding)
When a stage executes, events fire in this exact order:
1. Recorder.onStageStart — stage begins
2. Recorder.onRead — each typed property read (DURING execution)
3. Recorder.onWrite — each typed property write (DURING execution)
4. Recorder.onCommit — transaction buffer flushes to shared memory
5. Recorder.onStageEnd — stage completes
6. FlowRecorder.onStageExecuted — control flow records the stage
(CombinedNarrativeRecorder flushes buffered ops here)
7. FlowRecorder.onNext — moving to next stage
OR FlowRecorder.onDecision — if this was a decider (carries evidence from decide())
OR FlowRecorder.onFork — if children execute in parallel
OR FlowRecorder.onSelected — if this was a selector (carries evidence from select())
This ordering is what makes inline collection work. Scope events buffer during execution, flow events trigger the flush.
Anti-Patterns to Avoid
- Never post-process the tree. Don't walk the spec after execution to collect data. Use recorders.
- Don't use
getValue()/setValue() in TypedScope stages. Use typed property access (scope.amount = 50000). The old ScopeFacade API is internal only.
- Don't use
$-prefixed state keys (e.g., $break as a property name) — they collide with TypedScope's $-prefixed escape hatches ($getArgs, $getEnv, $break, $debug, $metric).
- Never use
CombinedNarrativeBuilder — it's deprecated. Use CombinedNarrativeRecorder (attached via executor.recorder(narrative())).
- Don't extract a shared base class for Recorder and FlowRecorder. They look similar but serve different layers. Two instances = coincidence.
- Don't call
$getArgs() for tracked data. $getArgs() returns frozen readonly input. Use typed scope properties for state that should appear in the narrative.
- Don't put infrastructure data in
$getArgs(). Use $getEnv() via run({ env }) for signals, timeouts, and trace IDs.
- Don't create scope recorders manually unless building a custom recorder.
executor.recorder(narrative()) handles everything.
Library Structure (for contributors)
src/lib/
├── memory/ → SharedMemory, StageContext, TransactionBuffer, EventLog (foundation)
├── schema/ → detectSchema, validate, InputValidationError (foundation)
├── builder/ → FlowChartBuilder, flowChart(), DeciderList, SelectorFnList (standalone)
├── scope/ → ScopeFacade, recorders/, providers/, protection/ (depends: memory)
├── reactive/ → TypedScope<T> deep Proxy, typed property access, $-methods, cycle-safe (depends: scope)
├── decide/ → decide()/select() decision evidence capture, filter + function when formats (depends: scope)
├── engine/ → FlowchartTraverser, handlers/, narrative/ (depends: memory, scope, reactive, builder)
├── runner/ → FlowChartExecutor, ExecutionRuntime (depends: engine, scope, schema)
└── contract/ → I/O schema + OpenAPI generation (depends: schema)
Dependency DAG: memory <- scope <- reactive <- engine <- runner, schema <- engine, builder (standalone) -> engine, contract <- schema, decide -> scope
Two entry points:
import { ... } from 'footprintjs' — public API
import { ... } from 'footprintjs/advanced' — internals (memory, traverser, handlers)
Common Patterns
Pipeline with decide() + narrative
import { flowChart, FlowChartExecutor, decide } from 'footprintjs';
interface LoanState {
applicantName: string;
income: number;
creditScore: number;
dti: number;
decision?: string;
reason?: string;
}
const chart = flowChart<LoanState>('Receive', (scope) => {
const args = scope.$getArgs<{ applicantName: string; income: number }>();
scope.applicantName = args.applicantName;
scope.income = args.income;
}, 'receive')
.addFunction('Analyze', (scope) => {
scope.creditScore = 750;
scope.dti = 0.35;
}, 'analyze')
.addDeciderFunction('Decide', (scope) => {
return decide(scope, [
{ when: { creditScore: { gt: 700 }, dti: { lt: 0.43 } }, then: 'approve', label: 'Good credit' },
{ when: (s) => s.creditScore > 600, then: 'approve', label: 'Marginal but acceptable' },
], 'reject');
}, 'decide')
.addFunctionBranch('approve', 'Approve', (scope) => {
scope.decision = 'approved';
scope.reason = 'Meets credit criteria';
})
.addFunctionBranch('reject', 'Reject', (scope) => {
scope.decision = 'rejected';
scope.reason = 'Does not meet credit criteria';
})
.setDefault('reject')
.end()
.build();
const executor = new FlowChartExecutor(chart);
await executor.run({ input: { applicantName: 'Bob', income: 42000 } });
const trace = executor.getNarrativeEntries();
Subflow with input/output mapping
interface SubState {
ssn: string;
score: number;
}
interface MainState {
ssn: string;
parentKey: string;
creditScore?: number;
}
const subflow = flowChart<SubState>('SubStart', subStartFn, 'sub-start')
.addFunction('SubProcess', subProcessFn, 'sub-process')
.build();
const main = flowChart<MainState>('Main', mainFn, 'main')
.addSubFlowChartNext('my-subflow', subflow, 'SubflowMount', {
inputMapper: (scope) => ({ ssn: scope.ssn }),
outputMapper: (subOut) => ({ creditScore: subOut.score }),
})
.build();
Attach multiple recorders
import { ManifestFlowRecorder, MilestoneNarrativeFlowRecorder, MetricRecorder } from 'footprintjs';
executor.attachScopeRecorder(new MetricRecorder());
executor.attachFlowRecorder(new ManifestFlowRecorder());
executor.attachFlowRecorder(new MilestoneNarrativeFlowRecorder());
await executor.run({ input: data });
const manifest = executor.getSubflowManifest();
const milestones = executor.getNarrativeEntries();