Visualising Awaitly Workflows with awaitly-visualizer
07 Feb 2026You have an Awaitly workflow: a few steps, some dependencies, typed results. It works. When someone asks "what does this do?" or you need to debug a run, you're left tracing through code.
What if you could see the same workflow as a diagram? awaitly-visualizer plugs into your workflow's events and turns them into that picture. For a checkout that runs fetchCart, validateCart, processPayment, then completeOrder, you get output like this:
┌── checkout ────┐
│ ✓ fetchCart │
│ ✓ validateCart│
│ ✓ processPayment
│ ✓ completeOrder
│ Completed │
└────────────────┘
Same idea as Mermaid flowcharts: steps, order, success and failure. This post walks through adding it step by step. All of the code below lives in a test in the repo so you can run it yourself.
Part 1: A minimal checkout workflow
Start with a workflow that has no visualisation. We'll use a simple checkout: fetch cart, validate, process payment, complete order. Each operation returns an AsyncResult; the workflow composes them with step().
import { ok, err, type AsyncResult } from "awaitly/core";
import { createWorkflow } from "awaitly/workflow";
type Cart = { id: string; itemCount: number; totalCents: number };
const fetchCart = async (
cartId: string
): AsyncResult<Cart, "CART_NOT_FOUND"> => {
if (cartId === "missing") return err("CART_NOT_FOUND");
return ok({
id: cartId,
itemCount: 2,
totalCents: 5999,
});
};
const validateCart = async (
cart: Cart
): AsyncResult<boolean, "INVALID_CART"> => {
if (cart.itemCount < 1) return err("INVALID_CART");
return ok(true);
};
const processPayment = async (
cart: Cart
): AsyncResult<{ transactionId: string }, "PAYMENT_FAILED"> => {
return ok({ transactionId: "txn-123" });
};
const completeOrder = async (
cartId: string
): AsyncResult<{ orderId: string }, "COMPLETE_ERROR"> => {
return ok({ orderId: `order-${cartId}` });
};
const deps = {
fetchCart,
validateCart,
processPayment,
completeOrder,
};
Run the workflow by creating it with a name and deps, then calling it with a function that uses step() for each operation:
const workflow = createWorkflow("checkout", deps);
const result = await workflow(
async (step, { fetchCart, validateCart, processPayment, completeOrder }) => {
const cart = await step("fetchCart", () => fetchCart("cart-1"));
await step("validateCart", () => validateCart(cart));
const payment = await step("processPayment", () => processPayment(cart));
const order = await step("completeOrder", () => completeOrder(cart.id));
return { cart, payment, order };
}
);
Nothing is visualised yet. The workflow just runs and returns a result. Next we wire in the visualiser so every step is reflected in a diagram.
Part 2: Live visualisation with createVisualizer
Create a visualiser with a workflow name (used as the diagram title). Pass its handleEvent into the workflow's onEvent option. After the workflow completes, call render() to get the default ASCII diagram, or renderAs("mermaid") for Mermaid source.
We'll use the same workflow body everywhere below; only the wiring changes. Here it is in full once:
import { createVisualizer } from "awaitly-visualizer";
const viz = createVisualizer({
workflowName: "checkout",
detectParallel: false, // this workflow is strictly sequential; with step.parallel() or step.race(), set true to show parallel/race scopes
});
const workflow = createWorkflow("checkout", deps, {
onEvent: viz.handleEvent,
});
// Same four-step workflow as Part 1; we only add onEvent above
await workflow(
async (step, { fetchCart, validateCart, processPayment, completeOrder }) => {
const cart = await step("fetchCart", () => fetchCart("cart-1"));
await step("validateCart", () => validateCart(cart));
const payment = await step("processPayment", () => processPayment(cart));
const order = await step("completeOrder", () => completeOrder(cart.id));
return { cart, payment, order };
}
);
console.log(viz.render());
console.log(viz.renderAs("mermaid"));
Example output from the blog-journey test (ASCII from viz.render(), Mermaid from viz.renderAs("mermaid")):
ASCII (terminal-style box):
┌── checkout ──────────────────────────────────────────────────────────────────┐
│ │
│ ✓ fetchCart [0ms] │
│ ✓ validateCart [0ms] │
│ ✓ processPayment [0ms] │
│ ✓ completeOrder [0ms] │
│ │
│ Completed in 2ms │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
Mermaid (flowchart source):
flowchart TD
start(("▶ Start"))
step_fetchCart["✓ fetchCart 0ms"]:::success
start --> step_fetchCart
step_validateCart["✓ validateCart 0ms"]:::success
step_fetchCart --> step_validateCart
step_processPayment["✓ processPayment 0ms"]:::success
step_validateCart --> step_processPayment
step_completeOrder["✓ completeOrder 0ms"]:::success
step_processPayment --> step_completeOrder
finish(("✓ Done")):::success
step_completeOrder --> finish
classDef success fill:#d1fae5,stroke:#10b981,stroke-width:3px,color:#065f46
%% other classDefs (pending, error, etc.) omitted for brevity
When a step fails
Everything so far is the happy path. If a step returns an error (for example fetchCart("missing") returns CART_NOT_FOUND), the workflow short-circuits and the visualiser shows the failure. Same code; run with a cart id that triggers the error:
// Same viz and workflow as above; only the input changes
const result = await workflow(
async (step, { fetchCart, validateCart, processPayment, completeOrder }) => {
const cart = await step("fetchCart", () => fetchCart("missing"));
// ... rest unchanged
}
);
// result.ok === false
ASCII (failed run):
┌── checkout ──────────────────────────────────────────────────────────────────┐
│ │
│ ✗ fetchCart [0ms] │
│ │
│ Failed in 0ms │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
Mermaid (failed run): the flowchart shows the failing step with an error style and a "✗ Failed" end node instead of "✓ Done". The diagram makes it obvious where the run stopped and that it did not reach later steps.
The visualiser consumes workflow events (step start, step success, workflow success, and so on) and builds an internal representation. That representation is what gets rendered as ASCII or Mermaid. You can also use renderAs("json") to inspect the structure, or renderAs("logger") for a step-by-step log style. Event handling is synchronous and lightweight, so it's fine for dev, test, and debugging; in production you might keep visualisation behind a feature flag or sample a fraction of runs if you want to limit overhead.
Part 3: Collect then visualise with createEventCollector
Sometimes you want to record events and visualise later, or send the same events to both a visualiser and your own logger or metrics. Use createEventCollector and wire its handlers; same workflow as above, only the onEvent and what you do after the run change:
import { createEventCollector } from "awaitly-visualizer";
const collector = createEventCollector({
workflowName: "checkout",
detectParallel: false,
});
const workflow = createWorkflow("checkout", deps, {
onEvent: collector.handleEvent,
});
await workflow(/* same workflow function as Part 2 */);
console.log(collector.visualize());
console.log(collector.visualizeAs("mermaid"));
The collector stores every workflow event it receives. When you call visualize(), it replays those events through an internal visualiser and returns the same kind of output you get from createVisualizer. You can also use collector.getEvents() to get the raw events, or visualizeEvents(events, options) if you've pushed events into your own array.
Part 4: Combine handlers
If you want both a live visualiser and another consumer (logging, metrics, or a collector for later), use combineEventHandlers so one run feeds all of them. Same workflow again; only the handler wiring changes:
import type { WorkflowEvent } from "awaitly/workflow";
import { createVisualizer, combineEventHandlers } from "awaitly-visualizer";
const viz = createVisualizer({
workflowName: "checkout",
detectParallel: false,
});
const collectedEvents: WorkflowEvent<unknown>[] = [];
const combined = combineEventHandlers(
viz.handleEvent,
(e: WorkflowEvent<unknown>) => collectedEvents.push(e)
);
const workflow = createWorkflow("checkout", deps, { onEvent: combined });
await workflow(/* same workflow function as Part 2 */);
console.log(viz.render());
// collectedEvents now holds every event for this run
Each event is passed to every handler you combined. You keep a single onEvent and get multiple observers.
Try it yourself
Install the packages:
npm install awaitly awaitly-visualizer
The code in this post is covered by tests in the awaitly repo. Clone and run the blog journey tests:
git clone https://github.com/jagreehal/awaitly
cd awaitly
pnpm install
pnpm --filter awaitly-visualizer test -- blog-journey
You'll see the same checkout workflow exercised with createVisualizer, createEventCollector, and combineEventHandlers. From there you can add visualisation to your own workflows by passing onEvent and, if you like, collecting events for logs or Mermaid in docs. At that point, your workflows stop being invisible control flow and start being artifacts you can reason about.