FlowBuilder
FlowBuilder is the animation layer of cloud-arch. After the topology is committed with topology.apply(), you create a FlowBuilder, define scenarios, and return flow from the script. The AnimationPlayer then drives playback.
const flow = new FlowBuilder();scenario
Declares a named scenario. All subsequent .from() / .to() / .showMessage() calls belong to this scenario until the next .scenario() call.
flow.scenario(
id: string,
title: string,
description?: string,
options?: ScenarioOptions
): FlowBuilder| Parameter | Type | Description |
|---|---|---|
id | string | Machine-readable identifier, unique within the script |
title | string | Human-readable name shown in the AnimationPlayer dropdown |
description | string | Optional subtitle shown below the title |
options.layer | string | When set, this scenario is only available when the named layer is active |
flow.scenario('happy-path', 'Happy Path', 'All services healthy');
flow.scenario('failover', 'Database Failover', 'Primary crashes, replica promotes', { layer: 'v2' });If you call .from() before any .scenario(), the steps are appended to an implicit default scenario.
from
Sets the current source node for subsequent .to() calls.
flow.from(nodeId: string): FlowBuilderfrom does not send a packet by itself — it only sets the cursor. The packet is sent when .to() is called.
flow
.from('client') // cursor = client
.to('api') // sends packet: client → api
.from('api') // cursor = api
.to('db'); // sends packet: api → dbto
Sends an animated packet from the current source node to nodeId, then sets the cursor to nodeId for the next step.
flow.to(nodeId: string): FlowBuilderto() resolves the edge between the current source and nodeId in both directions:
- Looks for a forward edge:
source → nodeId - If not found, looks for a reverse edge:
nodeId → sourceand animates it in reverse
This is how responses work without any additional edge definitions.
// Edge: connect('api', 'db')
flow
.from('api').to('db') // forward animation on edge api→db (query)
.from('db').to('api'); // REVERSE animation on same edge api→db (result)Bidirectional flow uses the same edge. Never add connect('db', 'api') to make responses work — to() already handles reverse traversal automatically. Adding a duplicate reverse edge creates two separate lines on the canvas and breaks the animation.
note — why the packet is flying
flow.to(nodeId: string, options?: { async?: boolean; waitForCompletion?: boolean; note?: string }): FlowBuildernote is a caption that stays on screen for the whole flight and answers the reader’s
question at the moment they ask it: why is this dot going there? showMessage() answers a
different question — what came back — and it can only run after the packet has landed.
flow
.from('api')
.to('db', { note: 'Читаем товар из базы' }) // висит, пока точка летит
.showMessage('[ДАННЫЕ] Строка получена'); // появляется после прилётаWithout note the caption is assembled from the edge label — API → Postgres · SELECT — and
rendered dimmer, because it is an engine hint and not your text. An edge with no label gets no
caption at all.
That hint never replaces your own text: it only fills silence. What does take over the subtitle during a flight is the caption of the step waiting at the other end — see below.
You usually get this for free
You rarely need note at all. When the step right after .to() carries a showMessage(), the
engine shows that text during the flight and keeps it after the landing:
flow
.from('api')
.to('db') // «[SELECT] …» уже на экране
.showMessage('[SELECT] Читаем товар из базы'); // и остаётся после прилётаSo a caption that describes what happens at the destination is read while the dot approaches, which is when the reader is actually asking. It then stays on screen through the landing — the subtitle does not blink or replay its fade-in.
Write captions accordingly: the showMessage() after a .to() should say what is about to happen
at the destination («[SELECT] Читаем товар из базы»), not narrate a finished fact. A caption that
can only be true after arrival belongs one step further down.
Nothing is pulled forward across an action(), and three things are never pulled forward at all:
showError()— an error is a result. “Already broken while still in flight” would be a lie.mark()captions — a checkpoint belongs to the pause it names, not to the flight before it.- a caption authored on a different node — it is not about this destination.
Reach for note when the flight needs a different sentence from the one waiting at the other
end, e.g. an intent on the way there and a result on the way back.
Intent before result. A step that carries showMessage() never sends a packet: the message
and the flight are separate steps, which is why a result-phrased caption always lands after the
dot. Put the intent in note, keep the outcome in showMessage().
showMessage
Displays a blue informational message banner at the bottom of the canvas during the current step.
flow.showMessage(text: string): FlowBuilderMessages appear immediately when the step they are attached to plays. They disappear when the next step begins.
flow
.from('client').to('api')
.showMessage('[GET] /api/users/42 — Authorization: Bearer ...')
.to('db')
.showMessage('[QUERY] SELECT * FROM users WHERE id = 42');Use [BRACKET] prefixes for message labels instead of emojis. This keeps the interface professional. Common prefixes: [REQUEST], [RESPONSE], [QUERY], [RESULT], [AUTH], [ERROR], [TIMEOUT], [HIT], [MISS], [OK], [RETRY].
showError
Displays a red error message banner at the bottom of the canvas and adds a red glow to the current node.
flow.showError(text: string): FlowBuilderUse showError for failures, timeouts, circuit open states, and any condition that represents a problem.
flow
.from('api').to('db')
.showError('[TIMEOUT] No response from database after 30s')
.from('api').to('client')
.showError('[503] Service temporarily unavailable');flashError
Applies a red glow to an arbitrary node without blocking the animation flow. The glow fades after approximately 2.5 seconds.
flow.flashError(nodeId: string): FlowBuilderUse flashError when you want to highlight a node as unhealthy while simultaneously sending a packet somewhere else — for example, showing a circuit breaker counter incrementing while a fallback response is already in flight.
flow
.from('cb')
.flashError('cb-counter') // highlight counter (non-blocking)
.showMessage('[COUNT] Failure count: 2/3')
.to('client')
.showError('[503] Upstream unavailable');parallel
Sends multiple packets simultaneously, one per sub-path.
flow.parallel(paths: [string, string][]): FlowBuilderEach entry in paths is a [source, target] pair. All packets are sent at the same time. The step completes when all packets have arrived.
flow
.from('leader')
.parallel([
['leader', 'follower-1'],
['leader', 'follower-2'],
['leader', 'follower-3'],
])
.showMessage('[REPLICATE] Write replicated to all followers');Timing and side effects
These steps carry no packet — they only occupy time or run code at their position in the step list, so they replay correctly during seek and fast-forward.
| Method | Signature | What it does |
|---|---|---|
delay | delay(ms: number) | Holds the current node for ms before the next step. Throws if from() was never called |
wait | wait(ms: number) | Alias for delay |
action | action(cb: (signal?: AbortSignal) => Promise<void> | void) | Runs arbitrary code at this point in the flow. The callback receives an AbortSignal — check signal.aborted (or pass it to fetch) so a Stop/Replay does not let async work finish against the next run’s canvas |
newRequest | newRequest() | Starts a new logical request: replica aliases (node-1, node-2) are re-picked from here on, so a second request can land on a different replica |
beginRequest | beginRequest() | Alias for newRequest |
flow
.from('client').to('api')
.delay(300)
.showMessage('[WAIT] Backpressure — request queued')
.action(async (signal) => {
await fetch('/v1/scripts/featured', { signal });
})
.newRequest()
.from('client').to('api') // may hit a different api-N replica
.showMessage('[RETRY] Second request');Runtime API
The AnimationPlayer drives these; scripts normally do not call them.
| Method | Purpose |
|---|---|
getSteps(scenarioKey?) | Steps of one scenario, or of the default flow |
getScenarioList() / hasScenarios() | Scenario pills shown by the player |
setActiveScenario(key | null) | Switch the scenario being authored or played |
resetForReplay(scenarioKey?) | Clear runtime state before a replay |
setFastForward(enabled) | Skip animation delays while seeking |
setRunSignal(signal) | Abort signal for the current run |
Critical rule: responses use reverse animation
This is the most common source of bugs in cloud-arch scripts.
Wrong approach — adding reverse edges:
// WRONG: creates two separate edge lines on the canvas
topology.connect('client', 'api', { protocol: 'http' });
topology.connect('api', 'client', { protocol: 'http' }); // <-- do not do thisCorrect approach — .from().to() in reverse order:
// CORRECT: one edge, animation goes both ways
topology.connect('client', 'api', { protocol: 'http' });
// Request (forward)
flow.from('client').to('api').showMessage('[GET] /data');
// Response (reverse on the same edge)
flow.from('api').to('client').showMessage('[200 OK] Here is your data');The same rule applies to all intermediate hops. A full request/response cycle through client → proxy → backend:
// Topology: two edges, no duplicates
topology.connect('client', 'proxy', { protocol: 'http' });
topology.connect('proxy', 'backend', { protocol: 'http' });
// Animation: four hops, two edges, both directions
flow
.from('client').to('proxy') // forward on edge client→proxy
.from('proxy').to('backend') // forward on edge proxy→backend
.from('backend').to('proxy') // reverse on edge proxy→backend
.from('proxy').to('client'); // reverse on edge client→proxyComplete example
const topology = new TopologyBuilder(true);
const g = topology.createGroup('app', { label: 'Application' });
g
.addProcess({ id: 'client', label: 'Client', state: 'running' })
.addProcess({ id: 'api', label: 'API Server', state: 'running' })
.addProcess({ id: 'db', label: 'PostgreSQL', shape: 'cylinder', state: 'running' })
.autoResize();
topology.connect('client', 'api', { protocol: 'http', label: 'REST' });
topology.connect('api', 'db', { protocol: 'postgresql', label: 'SQL' });
await topology.apply();
const flow = new FlowBuilder();
// --- Scenario 1: Happy path ---
flow.scenario('success', 'Happy Path', 'Query returns data');
flow
.from('client').to('api')
.showMessage('[GET] /data')
.to('db')
.showMessage('[QUERY] SELECT ...')
.from('db').to('api')
.showMessage('[ROWS] 10 records')
.from('api').to('client')
.showMessage('[200 OK] Data delivered');
// --- Scenario 2: Database timeout ---
flow.scenario('timeout', 'DB Timeout', 'Postgres does not respond');
flow
.from('client').to('api')
.showMessage('[GET] /data')
.to('db')
.showError('[TIMEOUT] No response after 30s')
.from('api').to('client')
.showError('[503] Database unreachable');
return flow;