# Visualising Awaitly Workflows with awaitly-visualizer

By Jag Reehal, 2026-02-07. Canonical: https://arrangeactassert.com/posts/visualising-awaitly-workflows-with-awaitly-visualizer/

<!-- Excerpt Start -->

You have an [Awaitly](https://github.com/jagreehal/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](https://github.com/jagreehal/awaitly/tree/main/packages/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:

```text
┌── 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.

<!-- Excerpt End -->

## 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()`.

```ts
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:

```ts
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:

```ts
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):**

```text
┌── checkout ──────────────────────────────────────────────────────────────────┐
│                                                                              │
│  ✓ fetchCart [0ms]                                                           │
│  ✓ validateCart [0ms]                                                        │
│  ✓ processPayment [0ms]                                                      │
│  ✓ completeOrder [0ms]                                                       │
│                                                                              │
│  Completed in 2ms                                                            │
│                                                                              │
└──────────────────────────────────────────────────────────────────────────────┘
```

**Mermaid (flowchart source):**

```mermaid
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:

```ts
// 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):**

```text
┌── 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:

```ts
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:

```ts
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:

```bash
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:

```bash
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.

Repo: [github.com/jagreehal/awaitly](https://github.com/jagreehal/awaitly)