Skip to content

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

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, and decision_*.
  • IR — the builder processes events into a tree of typed nodes (StepNode, ParallelNode, RaceNode, DecisionNode) under a root WorkflowNode. This is the WorkflowIR.
  • Renderers — each renderer walks the same IR tree. ASCII draws box-art, Mermaid generates flowchart TD, JSON serializes the IR directly.
import { ok, err, type Result } from 'awaitly';
import { createWorkflow } from 'awaitly/workflow';
import { createVisualizer } from 'awaitly-visualizer';
// Define your dependencies with Result-returning functions
const 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 output
console.log(viz.render());
// Mermaid output
console.log(viz.renderAs('mermaid'));

Events are only received by the visualizer when the workflow runtime calls your onEvent callback. That happens only if:

  1. You pass onEvent at workflow creation — e.g. createWorkflow('name', deps, { onEvent: viz.handleEvent }).
  2. 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 (or collector.handleEvent) where you call createWorkflow.
  • Library authors: Accept workflowOptions?: { onEvent?, ... } and spread them into createWorkflow(..., 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 actually 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.

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 options
export 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 visualization
const viz = createVisualizer({ workflowName: "processOnePayment" });
const result = await processOnePayment(batch, payment, deps, {
onEvent: viz.handleEvent,
});
console.log(viz.renderAs("mermaid"));

If you’re currently using manual onEvent logging, here’s how to migrate to createVisualizer:

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);
}
},
});
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 steps
FeatureManual LoggingcreateVisualizer
Timing per stepManual calculationAutomatic
Error formattingCustomStructured
Parallel detectionNot supportedAutomatic
Output formatsText onlyASCII, Mermaid, JSON, Flowchart
Diagram generationNot supportedBuilt-in
Post-execution analysisManualBuilt-in
┌── checkout ──────────────────────────────────────────────────────┐
│ │
│ ✓ Fetch order [12ms] │
│ ✓ Charge card [45ms] │
│ │
│ Completed in 57ms │
│ │
└──────────────────────────────────────────────────────────────────┘
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.

Get structured data for custom rendering:

const ir = viz.renderAs('json');
// {
// workflowName: 'checkout',
// status: 'completed',
// steps: [...],
// duration: 57
// }

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.

Track parallel operations with step.parallel(name, ...):

import { allAsync } from 'awaitly';
const result = await workflow.run(async ({ step, deps }) => {
const [user, posts] = await step.parallel('Fetch user data', () =>
allAsync([deps.fetchUser('1'), deps.fetchPosts('1')])
);
return { user, posts };
});

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.

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

when() from awaitly/conditional skips steps with a step_skipped event, rendered as in diagrams.

import { when } from 'awaitly/conditional';
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 discount

In Mermaid output, skipped steps get a :::skipped class with a dashed border.

To avoid passing ctx every time, use createConditionalHelpers:

import { createConditionalHelpers } from 'awaitly/conditional';
const { when, unless } = createConditionalHelpers({
workflowId: ctx.workflowId,
onEvent: ctx.onEvent,
});
// No need to pass ctx — it's bound automatically
const discount = await when(
user.isPremium,
() => step('Apply discount', () => deps.applyDiscount(order)),
{ name: 'Apply discount' }
);

Track conditional logic (if/switch) to visualize branching in your workflows:

import { trackDecision } from 'awaitly-visualizer';
const viz = createVisualizer();
const workflow = createWorkflow('workflow', deps, {
onEvent: viz.handleEvent,
});
await workflow.run(async ({ step, deps }) => {
const user = await step('Fetch user', () => deps.fetchUser('1'));
// Track a decision point
const decision = trackDecision('check-role', {
condition: \"user.role === 'admin'\",
value: user.role,
emit: viz.handleDecisionEvent,
});
if (user.role === 'admin') {
decision.takeBranch('admin', true);
await step('Admin dashboard', () => adminDashboard(user));
} else {
decision.takeBranch('user', true);
await step('User dashboard', () => userDashboard(user));
}
decision.end();
});
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();
import { trackSwitch } from 'awaitly-visualizer';
const decision = trackSwitch('process-by-role', user.role, {
condition: 'switch(user.role)',
emit: viz.handleDecisionEvent,
});
switch (user.role) {
case 'admin':
decision.case('admin', true);
await step('processAdmin', () => processAdmin(user));
break;
case 'moderator':
decision.case('moderator', true);
await step('processModerator', () => processModerator(user));
break;
default:
decision.default(true);
await step('processUser', () => processUser(user));
}
decision.end();
import { createEventCollector, trackIf } from 'awaitly-visualizer';
const collector = createEventCollector({ workflowName: 'Role Check' });
const workflow = createWorkflow('workflow', deps, {
onEvent: collector.handleEvent,
});
await workflow.run(async ({ step, deps }) => {
const user = await step('Fetch user', () => deps.fetchUser('1'));
const decision = trackIf('check-role', user.role === 'admin', {
condition: \"user.role === 'admin'\",
value: user.role,
emit: collector.handleDecisionEvent,
});
if (decision.condition) {
decision.then();
await step('processAdmin', () => processAdmin(user));
} else {
decision.else();
await step('processUser', () => processUser(user));
}
decision.end();
return user;
});
// Visualize with decision tracking
console.log(collector.visualize());

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 after
console.log(collector.visualize());
console.log(collector.visualizeAs('mermaid'));

Two mechanisms keep the visualizer in sync when you run a workflow more than once:

  • Automatic: Each workflow_start event 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 A
await 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 A
await workflow.run(async ({ step, deps }) => {
await step('Charge card', () => deps.chargeCard(100));
});
console.log(viz.render()); // shows Charge card (run B) only

Manual reset:

viz.reset(); // clears all state — getIR() returns an empty workflow
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)
});

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 tree
console.log(ir.root.children); // FlowNode[] — top-level steps, parallels, races, etc.
console.log(ir.metadata); // { createdAt, lastUpdatedAt }
TypeNodeCreated byKey fields
StepStepNodestep(), step.retry()state, durationMs, retryCount?, timedOut?
ParallelParallelNodestep.parallel()children, mode: "all" | "allSettled"
RaceRaceNodestep.race()children, winnerId?
DecisionDecisionNodetrackIf, trackSwitchbranches, branchTaken?
StreamStreamNodestreaming opsnamespace, writeCount, readCount

Each node has a state field. The visualizer maps states to symbols:

StateSymbolDescription
pendingNot yet started
runningCurrently executing
successCompleted successfully
errorFailed with error
abortedCancelled (e.g. race loser)
cachedResult served from cache
skippedSkipped by conditional

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 (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);

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),
),
});
RendererBest ForExample Use
ASCIITerminal debuggingQuick inspection during development
MermaidDocumentationGitHub READMEs, Confluence pages
LoggerProductionStructured logging with Pino/Winston
FlowchartComplex workflowsBox-and-arrow diagrams in terminal
JSONCustom renderingBuilding custom UIs or dashboards

Best for terminal-based debugging during development:

const output = viz.renderAs('ascii');
// ┌── checkout ──────────────────────────────────────────┐
// │ ✓ Fetch order [12ms] │
// │ ✓ Charge card [45ms] │
// │ Completed in 57ms │
// └──────────────────────────────────────────────────────┘

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

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', ... },
});

Best for complex workflows with box-and-arrow diagrams:

const flowchart = viz.renderAs('flowchart');
// ╭──────────────────╮
// │ Fetch order │
// ╰────────┬─────────╯
// │
// ▼
// ╭──────────────────╮
// │ Charge card │
// ╰──────────────────╯

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);
});

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 workflows
await workflow.run(async ({ step, deps }) => { ... });
await workflow.run(async ({ step, deps }) => { ... });

Generate Mermaid diagrams as build artifacts:

import fs from 'fs';
import { createVisualizer } from 'awaitly-visualizer';
import { createWorkflow } from 'awaitly/workflow';
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 artifacts
const mermaid = viz.renderAs('mermaid');
fs.writeFileSync('artifacts/workflow-diagram.md', `\\`\\`\\`mermaid\\n${mermaid}\\n\\`\\`\\``);

Learn about Testing →