API ReferenceFlowBuilder

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
ParameterTypeDescription
idstringMachine-readable identifier, unique within the script
titlestringHuman-readable name shown in the AnimationPlayer dropdown
descriptionstringOptional subtitle shown below the title
options.layerstringWhen 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): FlowBuilder

from 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 → db

to

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): FlowBuilder

to() resolves the edge between the current source and nodeId in both directions:

  1. Looks for a forward edge: source → nodeId
  2. If not found, looks for a reverse edge: nodeId → source and 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 }): FlowBuilder

note 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): FlowBuilder

Messages 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): FlowBuilder

Use 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): FlowBuilder

Use 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][]): FlowBuilder

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

MethodSignatureWhat it does
delaydelay(ms: number)Holds the current node for ms before the next step. Throws if from() was never called
waitwait(ms: number)Alias for delay
actionaction(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
newRequestnewRequest()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
beginRequestbeginRequest()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.

MethodPurpose
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 this

Correct 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→proxy

Complete example

flow-builder-example.ts
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;