It is somewhat essential when creating a SaaS product to have a great demo.

For the longest time I was manually managing a demo organisation in AgendaScope. It really wasn’t a very good demo — the items weren’t well thought out and it had gotten ‘crufty’ - and, worse, I kept having to manually “reset” it, which was becoming increasingly painful.

The straw that broke the camels back was me spending hours painstakingly building a new demo and then accidentally dropping the database it was in, with no backup. No, no, a thousand times no!

Alright… how cool would it be if I could whistle up a great demo, fresh every time I needed it? What would that take?

…some time later…

The answer is DemoGen: an elixir library for building demos from a “script” akin to a movie script. Here are some excerpts code from the AgendaScope “TechHarbour” demo script:

# Setup TechHarbour Ltd & its Leadership team

$subdomain = "tharbour"

delete_org {subdomain: $subdomain}

# Same password for all users
$password = "foo-bar-baz-qux"

# Some macros as short-hand for adjusting the clock
macro last_month = alter_clock {M: -1}
macro next_day = alter_clock {D: +1}

# Off we go, demo starts 1 month ago
@last_month

[09:00] add_org {as: org name: "TechHarbour" subdomain: $subdomain}
        set_feature_flag {org: org flag: "show_ai_assistant"}
        set_feature_flag {org: org flag: "show_score_heatmaps"}
        add_group {as: leadership org: org name: "Leadership"}
        set_objectives {
          group: leadership
          o_1: "MRR £1.2m (£0.8m)"
          o_2: "Top-3 Customers <45% (67%)"
          o_3: "Avg. Debtor Days 31 (75)"
        }

# Setup Emily Walker, CEO
[09:05] add_account {as: emily name: "Emily Walker" email: "e.walker@techharbour.com" password: $password}
        join_org {user: emily org: org admin: false}
        join_group {as: m_1 user: emily group: leadership role: "CEO" admin: true}

@next_day

# Add our first opportunity
[10:00] add_item {
          as: i_ai_research group: leadership creator: emily
          item_type: "opportunity" title: "Automating expensive research processes" public: true
          detail: "At the moment the core business is constrained by researchers doing a lot of manual research. Could we use AI to speed up the research process?"
        }

        tag_item {item: i_ai_research tag: "innovation"}
        set_tag_color {group: leadership tag: "innovation" color: "indigo"}
        tag_item {item: i_ai_research tag: "vision"}
        set_tag_color {group: leadership tag: "vision" color: "red"}

[10:01] set_champion {item: i_ai_research champion: emily}

[10:05] score_item {item: i_ai_research user: emily impact: 80 timeframe: 76 likelihood: 75}

[10:15] add_comment {
          as: c_ai_research_1 item: i_ai_research user: emily
          body: "Has anyone here seen an up-to-date review of OpenAI vs Anthropic?"
        }

@next_day

[10:15] score_item {item: i_ai_research user: sophie impact: 92 timeframe: 90 likelihood: 90}
[10:20] score_item {item: i_ai_research user: isabella impact: 45 timeframe: 45 likelihood: 45}
[10:21] score_item {item: i_ai_research user: oliver impact: 79 timeframe: 81 likelihood: 68}
[11:30] score_item {item: i_ai_research user: james impact: 95 timeframe: 98 likelihood: 88}

[11:45] add_comment {
          as: c_ai_research_2 item: i_ai_research user: isabella
          body: "I've no idea what any of this stuff means."
        }

[14:30] add_comment {
          as: c_ai_research_3 item: i_ai_research user: sophie
          body: "We have been experimenting with the APIs, GPT 4-o is better for 'reasoning' tasks while Claude seems to generate a better output."
        }

...

The syntax for the demo script uses # to start a line comment and $ to create a global value:

$variable = value

Global values can be referenced from within command arguments. Commands are the things that make changes.

Next we have macros that are a convenient shortcut for commonly used commands.

macro next_day = alter_clock {D: +1}

can be invoked via:

@next_day

Then we have time codes like:

[09:00]

which are a sugar for alter_clock {h: _, m: _}. The current clock date/time is passed as t to every command.

Lastly we have the demo-driven commands themselves add_org, set_feature_flag, add_group, and so on. These do the heavy lifting of creating/modifying application models.

Each command is implemented by a module that implements a particular Command behaviour.

Here is our add_org for example:

defmodule Radar.Demo.Commands.AddOrg do
  @impl Radar.Demo.Commands.Command
  def run(args, %{time: t, symbols: symbols} = context) do
    {:string, name} = Map.get(args, :name)
    {:string, subdomain} = Map.get(args, :subdomain)
    {:symbol, as} = Map.get(args, :as)

    with {:ok, %Org{} = org} <- create_org(t, name, subdomain) do
      {:ok, %{context | symbols: Map.put(symbols, as, org)}}
    end
  end
end

The run function is always called with a map of arguments and a context.

In the example the call to the add_org command there are three arguments as, name, and subdomain.

The context consists of the “current” datetime t and symbols which is a map of objects created by the demo already. This is actually one of the cool bits because it removes the need for database lookups in subsequent commands.

Looking at the previous example the add_org commands specifies as: org so that the newly created %Org{} model is stored under key the org in the symbols table.

Later when the add_group command specifies org: org that %Org{} model is retrieved from the symbols table and can be used directly.

In this way we build up our demo model-by-model. It’s really kind of neat.

When the demo script is parsed it automaticallly converts a command like add_org into a module name (in my case Radar.Demo.Commands.AddOrgCommand) and expects it to implement run/2 to implement its behaviour and stash any models it creates into the symbols table. So you implement a module for each command and away you go.

Whenever you add new functionality to your app you can create a new command module and add it to your demo script. That’s it.

Lastly DemoGen provides an Oban worker implementation that can run demo scripts. In AgendaScope we have a simple upload form that triggers the worker. In this way I can quickly reset the demo before a call.

At the moment the code is part of the AgendaScope project but I am intending to extract it into a separate library both for my own use and potentiall others if this is interesting.