Visualization
Visualize workflow execution as ASCII diagrams or Mermaid charts for debugging, documentation, or dashboards. Use step('id', fn, opts) so step IDs appear as stable labels in diagrams and in statically generated docs.
Packages: Runtime visualization uses awaitly-visualizer (createVisualizer, event handling, Mermaid/ASCII/JSON). The awaitly-visualizer app is an optional React UI that loads workflow diagrams (e.g. from static analysis or exported JSON). To capture events you must wire onEvent into your workflow (see Capturing events).
How it works
Section titled “How it works”Visualization is a three-stage pipeline: the workflow runtime emits typed events through onEvent, an internal IR builder assembles them into an IR tree, and a renderer walks the tree to produce output. You pick the renderer; everything else is automatic.
Workflow ──onEvent──▶ IR Builder ──▶ IR Tree ──▶ Renderer ──▶ Output(step_start, scope_start, ...) (WorkflowIR) (ascii, mermaid, json, ...)- Events: the workflow runtime emits typed events through
onEvent. Key types:step_start,step_success,step_error,step_retry,scope_start/scope_end(parallel/race),step_skipped, anddecision_*. - IR: the builder processes events into a tree of typed nodes (
StepNode,ParallelNode,RaceNode,DecisionNode) under a rootWorkflowNode. This is theWorkflowIR. - Renderers: each renderer walks the same IR tree. ASCII draws box-art, Mermaid generates
flowchart TD, JSON serializes the IR directly.
Basic usage
Section titled “Basic usage”import { ok, err, type Result, createWorkflow } from 'awaitly';import { createVisualizer } from 'awaitly-visualizer';
// Define your dependencies with Result-returning functionsconst deps = { fetchOrder: async (id: string): Promise<Result<Order, OrderNotFound>> => { const order = await db.orders.find(id); return order ? ok(order) : err({ type: 'ORDER_NOT_FOUND', id }); }, chargeCard: async (amount: number): Promise<Result<Payment, PaymentFailed>> => { const result = await paymentGateway.charge(amount); return result.success ? ok(result.payment) : err({ type: 'PAYMENT_FAILED', reason: result.error }); },};
const viz = createVisualizer({ workflowName: 'checkout' });
const workflow = createWorkflow('workflow', deps, { onEvent: viz.handleEvent,});
await workflow.run(async ({ step, deps }) => { const order = await step('fetchOrder', () => deps.fetchOrder('123')); const payment = await step('chargeCard', () => deps.chargeCard(order.total)); return { order, payment };});
// ASCII outputconsole.log(viz.render());
// Mermaid outputconsole.log(viz.renderAs('mermaid'));Capturing events
Section titled “Capturing events”Events are only received by the visualizer when the workflow runtime calls your onEvent callback. That happens only if:
- You pass
onEventat workflow creation, e.g.createWorkflow('name', deps, { onEvent: viz.handleEvent }). - Or you pass it at execution time: e.g.
workflow.run(fn, { onEvent: viz.handleEvent }), if your workflow API supports execution options.
If you wrap the workflow in a library (e.g. processOnePayment(batch, payment, deps, { onEvent })), the library must accept an optional onEvent (or full workflow options) and forward it to createWorkflow or to the executor. Otherwise the visualizer will never receive events.
- Direct usage: Always pass
onEvent: viz.handleEvent(orcollector.handleEvent) where you callcreateWorkflow. - Library authors: Accept
workflowOptions?: { onEvent?, ... }and spread them intocreateWorkflow(..., workflowOptions). See Library workflows below.
If the diagram is empty or only shows “workflow_start”, check that (1) onEvent is passed and (2) the workflow runs steps (so step_start / step_complete events are emitted). For an optional file-based capture (e.g. writing the last run to a cursor_events folder), see event-capture in the awaitly-visualizer package in the repo.
Live inspector
Section titled “Live inspector”Stream runs into the awaitly-analyze --dev inspector while you develop:
import { createWorkflow } from 'awaitly';import { devEvents } from 'awaitly-visualizer';
const workflow = createWorkflow('checkout', deps, { onEvent: devEvents('http://localhost:4747'),});
await workflow.run(async ({ step, deps }) => { /* ... */ });Start the inspector in another terminal:
npx awaitly-analyze ./src/workflows/checkout.ts --devdevEvents batches events per microtask and POSTs them fire-and-forget. A dead or absent inspector never affects the workflow. See Static Analysis → for --dev and --port.
Library workflows
Section titled “Library workflows”When your workflow is created inside a library (e.g. a payment or checkout module), callers need a way to pass visualization (or other) options. Expose an optional options argument and forward it to createWorkflow:
// Library: accept optional workflow optionsexport async function processOnePayment( args: ProcessOnePaymentArgs, deps: PaymentWorkflowDeps, workflowOptions?: { onEvent?: (event: WorkflowEvent<...>) => void }) { const workflow = createWorkflow("processOnePayment", deps, { catchUnexpected: ..., ...workflowOptions, // so callers can pass onEvent }); const args = input; return workflow.run(async ({ step, deps }) => { ... });}
// Caller: capture events for visualizationconst viz = createVisualizer({ workflowName: "processOnePayment" });const result = await processOnePayment(batch, payment, deps, { onEvent: viz.handleEvent,});console.log(viz.renderAs("mermaid"));Migration from manual logging
Section titled “Migration from manual logging”If you’re currently using manual onEvent logging, migrate to createVisualizer:
Before: Manual logging
Section titled “Before: Manual logging”const workflow = createWorkflow('workflow', deps, { onEvent: (event) => { if (event.type === 'step_start') { console.log(`Starting: ${event.name}`); } if (event.type === 'step_complete') { console.log(`Completed: ${event.name} in ${event.durationMs}ms`); } if (event.type === 'step_error') { console.error(`Failed: ${event.name}`, event.error); } },});After: Using createVisualizer
Section titled “After: Using createVisualizer”import { createVisualizer } from 'awaitly-visualizer';
const viz = createVisualizer({ workflowName: 'checkout' });const workflow = createWorkflow('workflow', deps, { onEvent: viz.handleEvent,});
// Run workflow...console.log(viz.render()); // ASCII diagram with all stepsBenefits comparison
Section titled “Benefits comparison”| Feature | Manual Logging | createVisualizer |
|---|---|---|
| Timing per step | Manual calculation | Automatic |
| Error formatting | Custom | Structured |
| Parallel detection | Not supported | Automatic |
| Output formats | Text only | ASCII, Mermaid, JSON, Flowchart |
| Diagram generation | Not supported | Built-in |
| Post-execution analysis | Manual | Built-in |
ASCII output
Section titled “ASCII output”┌── checkout ──────────────────────────────────────────────────────┐│ ││ ✓ Fetch order [12ms] ││ ✓ Charge card [45ms] ││ ││ Completed in 57ms ││ │└──────────────────────────────────────────────────────────────────┘Mermaid output
Section titled “Mermaid output”flowchart TD
start(("▶ Start"))
step_fetch_order[✓ Fetch order 12ms]:::success
start --> step_fetch_order
step_charge_card[✓ Charge card 45ms]:::success
step_fetch_order --> step_charge_card
finish(("✓ Done")):::success
step_charge_card --> finish
Paste into GitHub markdown or any Mermaid-compatible renderer.
JSON output
Section titled “JSON output”Get structured data for custom rendering:
const ir = viz.renderAs('json');// {// workflowName: 'checkout',// status: 'completed',// steps: [...],// duration: 57// }Naming steps
Section titled “Naming steps”Use a descriptive string as the first argument (step id) for better diagrams:
const user = await step('Fetch user', () => fetchUser('1'));const posts = await step('Fetch posts', () => fetchPosts(user.id));The step id is used for visualization and step identification.
Parallel operations
Section titled “Parallel operations”Track parallel operations with step.all(name, ...):
import { allAsync } from 'awaitly';
const result = await workflow.run(async ({ step, deps }) => { const [user, posts] = await step.all('Fetch user data', () => allAsync([deps.fetchUser('1'), deps.fetchPosts('1')]) ); return { user, posts };});Retry visualization
Section titled “Retry visualization”Retries are tracked automatically, the retry count appears in both ASCII and Mermaid output.
const payment = await step.retry( 'Fetch from API', () => deps.fetchFromApi(), { attempts: 3, backoff: 'exponential', initialDelay: 100 });In ASCII output, retries appear as a suffix after the timing:
✓ Fetch from API [320ms] [2 retries]In Mermaid output, retries render as a dashed self-loop edge:
flowchart TD
step_fetch["✓ Fetch from API 320ms"]:::success
step_fetch -.->|"↻ 2 retries"| step_fetch
See Retries & Timeouts for the full retry API.
Race visualization
Section titled “Race visualization”step.race() with anyAsync() shows all branches, highlights the winner, and marks losers as cancelled.
import { anyAsync } from 'awaitly';
const estimate = await step.race('Shipping estimate', () => anyAsync([ step('getPrimaryShipping', () => deps.getPrimaryShipping()), step('getFallbackShipping', () => deps.getFallbackShipping()), ]));In ASCII output, the race scope uses a ⚡ indicator with winner/loser annotations:
├⚡ ✓ Shipping estimate│ ├ ✓ getPrimaryShipping [5ms] (winner)│ └ ⊘ getFallbackShipping└── [5ms]In Mermaid output, the winner gets a thick arrow and losers get dashed “cancelled” edges:
flowchart TD
subgraph race_1["⚡ Shipping estimate"]
race_1_start(("🏁 Start"))
step_primary["✓ getPrimaryShipping 5ms"]:::success
step_fallback["⊘ getFallbackShipping"]:::aborted
race_1_end(("✓ First"))
race_1_start --> step_primary
race_1_start --> step_fallback
step_primary ==>|🏆 Winner| race_1_end
step_fallback -.->|cancelled| race_1_end
end
Conditional execution
Section titled “Conditional execution”when() from awaitly skips steps with a step_skipped event, rendered as ⊘ in diagrams.
import { when } from 'awaitly';
const discount = await when( user.isPremium, () => step('Apply discount', () => deps.applyDiscount(order)), { name: 'Apply discount', key: 'apply-discount', reason: 'User is not premium' }, { workflowId: ctx.workflowId, onEvent: ctx.onEvent });When the condition is false, the step is skipped. In ASCII output, skipped steps appear dimmed:
⊘ Apply discountIn Mermaid output, skipped steps get a :::skipped class with a dashed border.
To avoid passing ctx every time, use createConditionalHelpers:
import { createConditionalHelpers } from 'awaitly';
const { when, unless } = createConditionalHelpers({ workflowId: ctx.workflowId, onEvent: ctx.onEvent,});
// No need to pass ctx — it's bound automaticallyconst discount = await when( user.isPremium, () => step('Apply discount', () => deps.applyDiscount(order)), { name: 'Apply discount' });Decision tracking
Section titled “Decision tracking”step.if and step.branch emit decision events automatically. The visualizer builds DecisionNodes from onEvent alone; no tracker wiring needed.
step.if (automatic)
Section titled “step.if (automatic)”import { createWorkflow } from 'awaitly';import { createVisualizer } from 'awaitly-visualizer';
const viz = createVisualizer({ workflowName: 'checkout' });const workflow = createWorkflow('checkout', deps, { onEvent: viz.handleEvent });
await workflow.run(async ({ step, deps }) => { const user = await step('fetchUser', () => deps.fetchUser('1'));
if (step.if('premium-check', 'user.isPremium', () => user.isPremium)) { await step('sendPremiumEmail', () => deps.sendPremiumEmail(user)); } else { await step('sendStandardEmail', () => deps.sendStandardEmail(user)); }});
console.log(viz.renderAs('mermaid')); // decision node shows premium-check, branch takenUse step.if('id', 'label', () => condition) so the analyzer and visualizer get a stable decision id. See Control flow →.
step.branch (automatic)
Section titled “step.branch (automatic)”For branches that return values, step.branch emits scoped decision events (phase: 'start' / 'end') with conditionLabel:
const tier = await step.branch('route', { conditionLabel: 'amount > 100', condition: () => order.total > 100, then: () => 'premium', else: () => 'standard',});Legacy manual tracking
Section titled “Legacy manual tracking”Before step.if / step.branch, you wired trackIf, trackSwitch, or trackDecision from awaitly-visualizer and passed emit: viz.handleDecisionEvent. Prefer step.if / step.branch for new code; keep manual trackers only when you cannot refactor the branch.
trackIf / trackSwitch / trackDecision (legacy)
import { trackIf } from 'awaitly-visualizer';
const decision = trackIf('check-premium', user.isPremium, { condition: 'user.isPremium', emit: viz.handleDecisionEvent,});
if (decision.condition) { decision.then(); await step('fetchPremiumData', () => fetchPremiumData(user.id));} else { decision.else(); await step('fetchBasicData', () => fetchBasicData(user.id));}
decision.end();trackSwitch and trackDecision follow the same emit pattern. See package types for full signatures.
Post-execution visualization
Section titled “Post-execution visualization”Collect events and visualize later:
import { createEventCollector } from 'awaitly-visualizer';
const collector = createEventCollector({ workflowName: 'my-workflow' });
const workflow = createWorkflow('workflow', deps, { onEvent: collector.handleEvent,});
await workflow.run(async ({ step, deps }) => { ... });
// Visualize anytime afterconsole.log(collector.visualize());console.log(collector.visualizeAs('mermaid'));Reset and multiple runs
Section titled “Reset and multiple runs”Two mechanisms keep the visualizer in sync when you run a workflow more than once:
- Automatic: Each
workflow_startevent clears previous IR state. Reusing the same visualizer across runs means only the latest run is visible. - Manual:
viz.reset()clears all state explicitly.
Automatic replacement, only the second run is visible:
const viz = createVisualizer({ workflowName: 'checkout' });const workflow = createWorkflow('checkout', deps, { onEvent: viz.handleEvent });
// Run Aawait workflow.run(async ({ step, deps }) => { await step('Fetch order', () => deps.fetchOrder('A'));});console.log(viz.render()); // shows Fetch order (run A)
// Run B — automatically replaces run Aawait workflow.run(async ({ step, deps }) => { await step('Charge card', () => deps.chargeCard(100));});console.log(viz.render()); // shows Charge card (run B) onlyManual reset:
viz.reset(); // clears all state — getIR() returns an empty workflowOptions
Section titled “Options”const viz = createVisualizer({ workflowName: 'checkout', showTimings: true, // Show step durations (default: true) showKeys: false, // Show step cache keys (default: false) detectParallel: true, // Enable parallel detection (default: true)});IR tree structure
Section titled “IR tree structure”The IR is the data model connecting events to renderers. It’s exposed for custom renderers and programmatic inspection via viz.getIR().
const ir = viz.getIR();
console.log(ir.root); // WorkflowNode — the root of the treeconsole.log(ir.root.children); // FlowNode[] — top-level steps, parallels, races, etc.console.log(ir.metadata); // { createdAt, lastUpdatedAt }Node types
Section titled “Node types”| Type | Node | Created by | Key fields |
|---|---|---|---|
| Step | StepNode |
step(), step.retry() |
state, durationMs, retryCount?, timedOut? |
| Parallel | ParallelNode |
step.all() |
children, mode: "all" | "allSettled" |
| Race | RaceNode |
step.race() |
children, winnerId? |
| Decision | DecisionNode |
step.if, step.branch |
branches, branchTaken? |
| Stream | StreamNode |
streaming ops | namespace, writeCount, readCount |
Step states
Section titled “Step states”Each node has a state field. The visualizer maps states to symbols:
| State | Symbol | Description |
|---|---|---|
pending |
○ |
Not yet started |
running |
⟳ |
Currently executing |
success |
✓ |
Completed successfully |
error |
✗ |
Failed with error |
aborted |
⊘ |
Cancelled (e.g. race loser) |
cached |
↺ |
Result served from cache |
skipped |
⊘ |
Skipped by conditional |
Type guards
Section titled “Type guards”Use type guards from awaitly-visualizer for programmatic inspection:
import { isStepNode, isParallelNode, isRaceNode, isDecisionNode, isStreamNode, hasChildren, type FlowNode,} from 'awaitly-visualizer';
function walkTree(node: FlowNode) { if (isStepNode(node)) { console.log(`Step: ${node.name} [${node.state}]`); } else if (isDecisionNode(node)) { // A decision node nests its children under `branches`, not `children`. // Check it before `hasChildren`, which also matches decision nodes. node.branches.forEach((branch) => branch.children.forEach(walkTree)); } else if (isRaceNode(node)) { console.log(`Race: winner=${node.winnerId}`); node.children.forEach(walkTree); } else if (hasChildren(node)) { node.children.forEach(walkTree); }}
const ir = viz.getIR();ir.root.children.forEach(walkTree);Convenience APIs
Section titled “Convenience APIs”combineEventHandlers
Section titled “combineEventHandlers”Combine multiple event handlers for visualization + logging + metrics:
import { createVisualizer, combineEventHandlers } from 'awaitly-visualizer';
const viz = createVisualizer({ workflowName: 'checkout' });
const workflow = createWorkflow('workflow', deps, { onEvent: combineEventHandlers( viz.handleEvent, (e) => console.log(e.type), (e) => metrics.track(e), ),});When to use each renderer
Section titled “When to use each renderer”| Renderer | Best For | Example Use |
|---|---|---|
| ASCII | Terminal debugging | Quick inspection during development |
| Mermaid | Documentation | GitHub READMEs, Confluence pages |
| Logger | Production | Structured logging with Pino/Winston |
| Flowchart | Complex workflows | Box-and-arrow diagrams in terminal |
| JSON | Custom rendering | Building custom UIs or dashboards |
ASCII renderer
Section titled “ASCII renderer”Best for terminal-based debugging during development:
const output = viz.renderAs('ascii');// ┌── checkout ──────────────────────────────────────────┐// │ ✓ Fetch order [12ms] │// │ ✓ Charge card [45ms] │// │ Completed in 57ms │// └──────────────────────────────────────────────────────┘Mermaid renderer
Section titled “Mermaid renderer”Best for documentation that renders in GitHub, Confluence, or other Markdown viewers:
const mermaid = viz.renderAs('mermaid');// flowchart TD// start(("▶ Start"))// step_1[✓ Fetch order 12ms]:::success// ...Logger renderer
Section titled “Logger renderer”Best for production logging with structured output:
import { loggerRenderer } from 'awaitly-visualizer';
const renderer = loggerRenderer();const output = renderer.render(viz.getIR(), { showTimings: true, showKeys: false, colors: { success: 'green', error: 'red', ... },});Flowchart renderer
Section titled “Flowchart renderer”Best for complex workflows with box-and-arrow diagrams:
const flowchart = viz.renderAs('flowchart');// ╭──────────────────╮// │ Fetch order │// ╰────────┬─────────╯// │// ▼// ╭──────────────────╮// │ Charge card │// ╰──────────────────╯Integration patterns
Section titled “Integration patterns”Express middleware
Section titled “Express middleware”Log and visualize workflows in HTTP handlers:
import { createEventCollector } from 'awaitly-visualizer';
app.post('/checkout', async (req, res) => { const collector = createEventCollector({ workflowName: 'checkout' });
const workflow = createWorkflow('workflow', deps, { onEvent: collector.handleEvent, });
const result = await workflow.run(async ({ step, deps }) => { const order = await step('Fetch order', () => deps.fetchOrder(req.body.orderId)); const payment = await step('Charge card', () => deps.chargeCard(order.total)); return { order, payment }; });
// Log visualization on error for debugging if (!result.ok) { logger.error('Checkout failed', { visualization: collector.visualize(), events: collector.getEvents(), }); return res.status(500).json({ error: result.error }); }
res.json(result.value);});Event collector for batched logging
Section titled “Event collector for batched logging”Collect events across multiple workflow runs for aggregated analysis:
import { createEventCollector } from 'awaitly-visualizer';
const allEvents: CollectableEvent[] = [];
const workflow = createWorkflow('workflow', deps, { onEvent: (e) => allEvents.push(e),});
// Run multiple workflowsawait workflow.run(async ({ step, deps }) => { ... });await workflow.run(async ({ step, deps }) => { ... });CI/CD artifact generation
Section titled “CI/CD artifact generation”Generate Mermaid diagrams as build artifacts:
import fs from 'fs';import { createVisualizer } from 'awaitly-visualizer';import { createWorkflow } from 'awaitly';
const viz = createVisualizer({ workflowName: 'deployment' });
const workflow = createWorkflow('workflow', deps, { onEvent: viz.handleEvent,});
await workflow.run(async ({ step, deps }) => { await step('Run tests', () => deps.runTests()); await step('Build app', () => deps.buildApp()); await step('Deploy', () => deps.deploy());});
// Write Mermaid diagram to artifactsconst mermaid = viz.renderAs('mermaid');fs.writeFileSync('artifacts/workflow-diagram.md', `\\`\\`\\`mermaid\\n${mermaid}\\n\\`\\`\\``);Static analysis vs runtime visualization
Section titled “Static analysis vs runtime visualization”Use the right tool for the job:
| Tool | Input | Output | Best for |
|---|---|---|---|
awaitly-visualizer |
Live onEvent stream during a run |
ASCII, Mermaid, JSON from runtime IR | Debugging a specific execution, dashboards, dev tools |
awaitly-analyze |
Source TypeScript (no execution) | Static Mermaid, JSON, HTML | Architecture docs, PR diffs, CI gates |
awaitly-analyze --trace |
Static IR + recorded onEvent JSON |
Mermaid with executed path highlighted | Post-mortems, run reports on diagrammable workflows |
awaitly-analyze --dev |
Static IR + live onEvent stream |
Browser inspector with runtime trace overlay | Local development; pair with devEvents |
Runtime visualizer builds an IR from workflow events. Its diagram shows one execution.
Static analysis extracts structure from source code. Its diagram represents the code path rather than one execution. Workflows using declarative constructs (step.if, when/unless, step.forEach, literal step ids) are fully diagrammable. Use --trace or renderStaticMermaidWithTrace to add a recorded event trace to that diagram.
Typical workflow: generate static diagrams in CI with awaitly-analyze, capture onEvent output in production or tests, then overlay traces for incident review:
# CI: enforce deterministic diagramsnpx awaitly-analyze ./src/workflows/checkout.ts --assert-diagrammable
# After a run: overlay the executed pathnpx awaitly-analyze ./src/workflows/checkout.ts --trace=./run-events.jsonSee Static Analysis → for the full CLI and programmatic API.