Optionalprefix: stringOptionaldescription: stringOptionaloptions: { retry?: RetryPolicy; tags?: readonly string[]; touches?: StageTouches }
{ retry } gives the DECIDER STAGE a retry policy. Declared here rather
than via .retry() because this method returns a DeciderList, where a
chained modifier could mean the decider OR the branch just added;
{ tags } likewise.
Add a stage that fires a child flowchart on the given driver and DISCARDS the handle. Pure fire-and-forget — useful for telemetry exports, audit log shipping, cache warm-up.
Stable id for this stage (also the stageMap key).
The child flowchart to detach.
Optionaldescription?: stringStage description for narrative + tools.
The driver to schedule on (e.g. microtaskBatchDriver).
OptionalinputMapper?: (scope: TScope) => unknownMaps the parent's scope to the child's input.
Defaults to passing undefined.
OptionalmountName?: stringDisplay name; defaults to id.
import { microtaskBatchDriver } from 'footprintjs/detach';
flowChart('process', processFn, 'process')
.addDetachAndForget('telemetry', telemetryChart, {
driver: microtaskBatchDriver,
inputMapper: (scope) => ({ event: 'processed', orderId: scope.orderId }),
})
.addFunction('next', nextFn, 'next')
.build();
Add a stage that fires a child flowchart on the given driver and
delivers the resulting DetachHandle to a consumer-supplied
onHandle callback. The handle CANNOT be stored in shared state
— StageContext.setValue calls structuredClone which drops
class prototypes (and therefore the handle's .wait() method).
The callback pattern is the explicit alternative: keep handles in
a closure-local array (or whatever shape suits) and have a
downstream stage await Promise.all(...) over them.
import { microtaskBatchDriver } from 'footprintjs/detach';
import type { DetachHandle } from 'footprintjs/detach';
const handles: DetachHandle[] = [];
const chart = flowChart('seed', seedFn, 'seed')
.addDetachAndJoinLater('eval-a', evalChart, {
driver: microtaskBatchDriver,
inputMapper: (scope) => scope.configA,
onHandle: (h) => handles.push(h),
})
.addDetachAndJoinLater('eval-b', evalChart, {
driver: microtaskBatchDriver,
inputMapper: (scope) => scope.configB,
onHandle: (h) => handles.push(h),
})
.addFunction('join', async (scope) => {
const settled = await Promise.all(handles.map((h) => h.wait()));
scope.results = settled;
}, 'join')
.build();
Note: putting handles in a module-level closure is fine for
single-run scripts. For server code that runs the same chart
concurrently across requests, allocate a new closure per run
(e.g., wrap chart construction in a factory function) so handles
from different runs don't bleed into each other.
Optionaldescription: stringOptionalmountName: stringOptionaloptions: SubflowMountOptions<any, any, any>OptionalmountName: stringOptionaloptions: SubflowMountOptions<any, any, any>Optionaloptions: { failFast?: boolean }Run ONE BRANCH PER ITEM, in parallel — with the number of branches decided at RUN time from the payload rather than at build time.
addListOfFunction fans out a fixed set of branches you name in the chart.
This fans out over data the chart has not seen yet: three chunks make three
branches, ten make ten. Each branch runs as its own isolated subflow — its
own memory, its own commit log, addressable in every trace query at
<stageId>~<index> — so branches cannot corrupt each other's in-flight
state, and a backward slice through a branch result works with no special
handling. Design: docs/design/execution-control.md.
Display label for the fan-out stage.
Stable stage id. May not contain ~ (reserved — the branch
segments are built from this id).
{ items, branch, maxBranches, into, failFast? }:
items(scope) picks what to fan out over (reads are tracked);branch(item, index) builds the chart for ONE item (each branch's
scope is also seeded with item and index, so the input is in the
trace rather than hidden in a closure);maxBranches is REQUIRED — an unbounded fan-out driven by model output
is a resource attack, so there is no default to forget. Extra items do
not run and the truncation is recorded;into is REQUIRED — the state key the ordered results array is written
to. into[i] is branch i's result, in ITEMS order, whatever order
the branches finished in;failFast is the same policy the rest of the library's parallelism
uses: true rejects on the first failing branch, omitted runs them all
best-effort (a failed branch's slot is undefined).Optionaldescription: stringflowChart<State>('Split', splitFn, 'split')
.addParallelForEach('Review each chunk', 'review-chunks', {
items: (scope) => scope.chunks,
branch: (chunk, i) => buildReviewChart(chunk, i),
maxBranches: 8,
into: 'reviews',
})
.addFunction('Merge', mergeFn, 'merge') // reads scope.reviews
.build();
Add a pausable stage — can pause execution and resume later with input.
The handler has two phases:
execute: runs first time. Return any non-void value to pause (it becomes
the checkpoint's pauseData); return void/undefined to continue normally.resume: runs when the flowchart is resumed with input.Optionaldescription: stringCompose a keyed outside operation, reconciliation, shared retry decision and wait as ordinary chart nodes.
Optionaldescription: stringOptionaloptions: {{ retry } gives the SELECTOR STAGE a retry policy — same reasoning as
addDeciderFunction: this method returns a sub-builder, so a chained
.retry() would be ambiguous; { tags } likewise.
OptionalstreamId: stringOptionaldescription: stringOptionalmountName: stringOptionaloptions: SubflowMountOptions<any, any, any>OptionalmountName: stringOptionaloptions: SubflowMountOptions<any, any, any>Attach a StructureRecorder for build-phase observation. Multiple
recorders coexist (same id allowed; iteration order = attach
order). Throws if called after .build() — the chart is sealed at
that point and any recorder attached late would miss every event
fired during construction.
Seed replay: when this is called AFTER start() has already
fired (i.e., after the flowChart() factory returns), the
just-attached recorder receives a one-time onStageAdded for the
root stage so it observes the seed. Only the new recorder sees
the replay; already-attached recorders are not re-fired.
Mid-chain attach caveat: a recorder attached AFTER one or more
addX() calls receives the seed replay but MISSES every
intermediate event. Attach BEFORE the first addX() for complete
capture.
Public for now to enable direct attach in tests + early consumers.
L7.4 will wire flowChart(..., { structureRecorders: [...] }) as
an additional registration site; this method will remain.
Declare the chart's time budget: the most milliseconds a caller gives one run (design §3.2).
WHY: the incident behind it — a stage retried an SDK call four times, 40 s each, past a caller's 120 s, and the
record said "one stage, one error". Both numbers were declared: the policy on the stage, the budget of the
caller. So build() checks one against the other: it REFUSES the chart when its declared policies already allow
longer (attempts × timeoutMs plus the waits, summed along the slowest path — see timeBudget), and in dev mode
names every stage it cannot bound (no timeoutMs, a function backoff with no maxBackoffMs, a loop, a lazy
subflow, a parallelForEach). The spec root carries it (budgetMs); at run start, a deadline shorter than it is
a dev-mode warning.
Chart-level, so it may be declared anywhere before build(), once.
flowChart<State>('Read', readFn, 'read', { retry: { attempts: 1, timeoutMs: 5_000 } })
.addFunction('Charge', chargeFn, 'charge')
.retry({ attempts: 4, timeoutMs: 40_000, backoffMs: 1_000 })
.budget(120_000)
.build(); // throws: its declared policies allow 168000 ms, over its budget by 48000 ms
Declare the API contract — input validation, output shape, and output mapper, in one call.
If a contract with input schema is declared, chart.run() validates input automatically. Contract data is used by chart.toOpenAPI() and chart.toMCPTool().
Inspect accumulated StructureBuildErrors. Returns empty array
when no recorders attached OR no errors occurred. Returns a
defensive copy — caller mutations do not affect subsequent calls.
Call on the BUILDER, not the chart returned by .build().
Capture the builder reference before .build() if you need
post-build access:
const builder = flowChart(...).attachStructureRecorder(rec);
const chart = builder.build();
const errors = builder.getStructureBuildErrors();
Make a loop back to stageId the current stage's continuation. The loop
ends this path: a door that would attach a stage after it is refused
(_needContinuationCursor), so add every stage before loopTo().
Give the stage you JUST added a declarative retry policy.
A retry hand-rolled inside a stage function is invisible to the trace. The
narrative shows one stage, the commit log shows one entry, and the two
failed calls that came before the successful one left no mark anywhere —
an unexplained behaviour in a library whose whole point is that nothing
invisible happens. Declared here, every attempt is part of the record: each
attempt fires FlowRecorder.onAttempt with its AttemptRecord (outcome,
duration, failure category, the retry-or-stop decision), which is what
feeds the narrative — one line per attempt, in order, between the
attempts' own reads and writes. The stage's execution-tree node keeps the
records (attempts, attemptSummary). A failed attempt that is retried
also fires FlowRecorder.onStageRetry, which the narrative does not tell
twice.
A failed attempt's staged writes are DISCARDED — the next attempt starts from committed state, never from the wreckage of the last try. The final attempt behaves exactly as a stage with no policy does: on success it commits; on failure it commits what it wrote and rethrows (footprint has never had rollback, and retry does not introduce one).
A pause is not a failure: neither addPausableFunction's pause nor
interrupt() is ever retried. Nor is a cancelled run.
flowChart<State>('Fetch user', fetchFn, 'fetch-user')
.addFunction('Charge card', chargeFn, 'charge')
.retry({ attempts: 3, backoffMs: (n) => 100 * 2 ** (n - 1) })
.addFunction('Confirm', confirmFn, 'confirm')
Applies to the CURRENT cursor — the stage added by the immediately
preceding start() / addFunction() / addStreamingFunction() /
addPausableFunction(). Where the cursor would be ambiguous (a decider,
selector, branch, or fork child), declare the policy in that method's own
retry option instead.
Override the scope factory. Rarely needed — auto-embeds TypedScope by default.
Optionaldescription: stringOptionaloptions: { retry?: RetryPolicy; tags?: readonly string[]; touches?: StageTouches }Start a chart whose ROOT stage IS a selector — it runs first (reading
args, seeding state, returning the chosen branch ids via select()),
and its branches attach directly to the root. Mirrors start() for the
root-node setup, then returns a SelectorFnList bound to the root so
.addFunctionBranch() / .addSubFlowChartBranch() / .end() work
exactly as they do after addSelectorFunction().
Use when the first thing a chart does is choose among branches — e.g. a
Context selector that inits + picks which context slots to engineer,
with no separate seed stage before it.
Optionaldescription: stringOptionaloptions: {Put NAMES on the stage you JUST added. The first commit bundle of each
execution of that stage carries them (CommitBundle.tags — the bundle
commitStops keys on), and the built spec advertises them
(SerializedPipelineStructure.tags).
A reader that wants "the LLM turns" of a stored recording had to classify
stages from their ids — a switch over runtimeStageId that parses # and
/, lives in the consumer, and silently goes stale when a stage is
renamed. Declared here, the name travels WITH the commit: a recording from
any chart carries its own milestones, and tagStops (foottrace)
scrubs them with no id conventions at all.
A tag is a NAME declared at build time — never a value. There is no
run-time $tag(): a runtime string could carry data ('user:' + email)
past every redaction point. Data-dependent marks are a keep rule over
the fold at read time, or telemetry via $emit. Free strings: footprintjs
owns no vocabulary; a consumer declares its own ('milestone:<kind>').
Stamped ONCE per execution of the stage: retry attempts share one stamp,
a failed stage keeps its tag (the error path commits before it rethrows),
a stage that interrupt()s and is resumed is two tagged stops on a chain
because it ran twice, and an EMPTY commit is a tagged stop too. A subflow
mount or a parallel-for-each stage can be tagged; the tag lands on the
bundle that records its result — for a mount, its FIRST bundle in the
parent log (the exit bundle carries none), while the subflow's own log
is untouched: its inner stages declare their own.
flowChart<State>('Seed', seedFn, 'seed')
.addFunction('Call model', callFn, 'call-llm')
.tag('milestone:llm-turn')
.addFunction('Route', routeFn, 'route')
.tag('milestone:decision', 'audit')
Applies to the CURRENT cursor — the stage added by the immediately
preceding start() / addFunction() / addStreamingFunction() /
addPausableFunction() / addSubFlowChartNext() / addParallelForEach().
Where the cursor would be ambiguous (a decider, selector, branch, or fork
child), declare the tags in that method's own tags option instead. A
subflow MOUNT has that option too (SubflowMountOptions.tags, 9.21.1)
— and for a fork-child mount (addSubFlowChart) or a branch mount
(addSubFlowChartBranch) it is the ONLY site: those leave the cursor on
the parent, so .tag() after them refuses rather than mis-attribute.
Refused at build time: an empty name, a non-string, the reserved
branch-segment marker ~ inside a name, a name declared twice, and a
second declaration on the same stage.
The graph so far as Mermaid text — the same walker as the built chart's toMermaid() (chartToMermaid).
Declare that the stage you JUST added changes an outside system — a charge, an email, a write to another service (10.1, design §3.3) — and give the rule for its idempotency key.
A retry is safe for a read. For a charge it is safe only when the outside system can tell a retry from a new
request — by an idempotency key. So the builder refuses a touching stage that may retry (.retry({ attempts > 1 }), in either order) without a key rule: a double charge must be impossible to write by accident. A default
key made by the library was rejected — the run id changes across a resume and a caller's whole-run retry, the
two places a double charge actually happens. The key must name the OPERATION: derive it from the input the rule
is handed, never from model output (({ getState }) => … reads it from state). A
keyless touching stage may not interrupt(): a resume re-runs it from its top. In a chart that pauses, derive
keys from state (({ getState }) => …): the run's input does not cross a resume.
The stage always runs on the attempt runner. The key rule runs ONCE per execution, before attempt 1, and every
attempt gets the key as scope.$attempt().idempotencyKey — pass it to the system. Every attempt record says
what it did there (effect: 'applied', 'none' when the error itself declares effect: 'none', else
'unknown'). When ANY attempt left the effect unknown — it timed out, was cut, or failed without the adapter
saying nothing was sent — the stage fails with UnknownEffectError: reconcile with the system by the key, do
not retry blindly. The spec carries the system's name (touches).
Optionaloptions: { idempotencyKey?: (context: IdempotencyKeyContext) => string }flowChart<Order>('Load order', loadFn, 'load')
.addFunction('Charge card', chargeFn, 'charge')
.touches('stripe', { idempotencyKey: ({ args }) => `order-${(args as { orderId: string }).orderId}` })
.retry({ attempts: 3, timeoutMs: 20_000 })
Applies to the CURRENT cursor, like .retry(); where the cursor would be ambiguous (a decider, selector,
branch, or fork child), declare it in that method's own touches option. Refused: on a subflow mount, a
parallel-for-each stage or a loop reference (no function of their own), after a mount or a fork (the cursor did
not move), twice, with a system that is not a name, and with a key that is not a function.
THE mount of a subflow chart under
id(F10) — every eager mount site (addSubFlowChart,addSubFlowChartNext, a decider/selectoraddSubFlowChartBranch) goes through it, and it is the one caller of the prefixer. In order: claimid(_claimFreshId— refused when another stage, functionless stage, claimed id or generated fork node holds it; a prior subflow mount under the same id is the one exception, it already holds the claim — see_registerSubflowDef); register the chart's prefixed root underid;placethe mount's own node + spec and fire its structure events; merge the chart's stage map underid; register its nested subflows underid/<key>(a key already registered is kept).