mattmower

mattmower

DemoGen — Create repeatable demo scenarios for SaaS prospect meetings (v0.1.7)

I’m pleased to announce that we’ve released DemoGen v0.1.7, a library for creating repeatable demo scenarios in your Ecto-based SaaS application, under the MIT license.

Push a button and immediately have a demo site, looking exactly the way you want people to see it.

As CEO, I’m often having demoing AgendaScope to prospects, or need to show off some part of the application (e.g. to record a video). But creating and maintaining a demo site is a tedious headache. Resetting it to a known good state can be awkward, or I forget so that the demo doesn’t look right next time I need it. And it is very difficult to vary or experiment with what I am presenting.

What I needed was a demo site, that would always look exactly as I wanted it to, when I wanted it to. That means I need to be able to reset it to known, good, state in 30s or less. I also need to be able evolve my demo as I learn what works best, and to be able to create variations to show specific scenarios.

What I needed was a scriptable demo site. That’s what DemoGen gives me.

The library consists of a parser for a simple demo-script DSL as well as a runner to execute scripts.

Here’s a chunk of my current “TechHarbour” demo script:

# Setup TechHarbour Ltd & its Leadership team

$subdomain = "tharbour"

# Get rid of the demo site if it exists
delete_org {subdomain: $subdomain}

# Same password for all users
$password = "beep-beep"

# 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: "Achieve product/market fit"
          o_2: "Sustainable revenue model"
          o_3: "Scale engineering operations"
        }

@next_day

# Setup Emily Walker, CEO
[09:00] 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}

# Setup James Patel, COO
[09:01] add_account {as: james name: "James Patel" email: "j.patel@techharbour.com" password: $password}
        join_org {user: james org: org admin: false}
        join_group {as: m_2 user: james group: leadership role: "COO" admin: true}

# Setup Sophie Turner, CTO
[09:02] add_account {as: sophie name: "Sophie Turner" email: "s.turner@techharbour.com" password: $password}
        join_org {user: sophie org: org admin: true}
        join_group {as: m_3 user: sophie group: leadership role: "CTO" admin: false}

@next_day

# Setup Oliver Bennett, CMO
[09:00] add_account {as: oliver name: "Oliver Bennett" email: "o.bennett@techharbour.com" password: $password}
        join_org {user: oliver org: org admin: false}
        join_group {as: m_4 user: oliver group: leadership role: "CMO" admin: false}

# Setup Isabella Smith, CFO
[09:01] add_account {as: isabella name: "Isabella Smith" email: "i.smith@techharbour.com" password: $password}
        join_org {user: isabella org: org admin: false}
        join_group {as: m_5 user: isabella group: leadership role: "CFO" admin: false}

# Setup Kai Chen, CPO
#[09:03] add_account {as: kai name: "Kai Chen" email: "k.chen@techharbour.com" password: $password}
#        join_org {user: kai org: org admin: false}
#        join_group {as: m_6 user: kai group: leadership role: "CPO" admin: false}

@next_day

[09:00] add_item {
          as: i_inflation group: leadership creator: isabella
          item_type: "risk" title: "Above average increases in inflation" public: true
          detail: "Rising inflation rates are increasing operational costs while reducing customer spending power and putting pressure on our margins."
        }

[09:01] set_champion {item: i_inflation champion: isabella}
        score_item {item: i_inflation user: isabella impact: 81 timeframe: 75 likelihood: 90 effort: 60}

[09:05] add_item {
          as: i_expenses group: leadership creator: isabella
          item_type: "risk" title: "Expenses up over 20% m-o-m" public: true
          detail: "Expenses have risen above inflation and are cutting into operational margins."
        }

[09:06] set_champion {item: i_expenses champion: isabella}
[09:10] score_item {item: i_expenses user: isabella impact: 85 timeframe: 95 likelihood: 90 effort: 50}

[10:00] tag_item {item: i_expenses tag: "finance"}
        tag_item {item: i_inflation tag: "finance"}
[10:01] set_tag_color {group: leadership tag: "finance" color: "teal"}

@next_day

[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 effort: 60}

[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 effort: 60}
[10:20] score_item {item: i_ai_research user: isabella impact: 45 timeframe: 45 likelihood: 45 effort: 60}
[10:21] score_item {item: i_ai_research user: oliver impact: 79 timeframe: 81 likelihood: 68 effort: 60}
[11:30] score_item {item: i_ai_research user: james impact: 95 timeframe: 98 likelihood: 88 effort: 60}

[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."
        }

@next_day

[09:15] score_item {item: i_inflation user: emily impact: 60 timeframe: 50 likelihood: 50 effort: 60}
[09:20] score_item {item: i_inflation user: oliver impact: 71 timeframe: 60 likelihood: 55 effort: 60}
[09:21] score_item {item: i_inflation user: james impact: 63 timeframe: 71 likelihood: 40 effort: 60}
[09:25] score_item {item: i_inflation user: sophie impact: 32 timeframe: 31 likelihood: 25 effort: 60}

…

There’s a lot more, similar stuff.

Some things to note:

DemoGen keeps track of the “current time” and passes it to each command. The alter_clock command is used to change the date/time which we can either do with a macro like @next_day or using the built in sugar-syntax [HH:MM].

Each command has a name like add_org and a map of parameters such as as:, name:, and subomain: that get passed to the command. You can declare variables like $password to pass the same value to multiple commands.

Each command is run by a module that implements the DemoGen.Command behaviour, for example:

defmodule Radar.Demo.Commands.AddOrg do
  import Ecto.Changeset

  use DemoGen, command_name: "add_org"
  alias DemoGen.Command

  alias Radar.Orgs.Org
  alias Radar.Repo

  @impl DemoGen.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

  defp create_org(t, name, subdomain) do
    %Org{}
    |> Org.create_changeset(%{
      "name" => name,
      "subdomain" => subdomain
    })
    |> demo_changeset(%{
      "demo" => true
    })
    |> Command.set_timestamps(t)
    |> Repo.insert()
  end

  defp demo_changeset(org, attrs) do
    org
    |> cast(attrs, [:demo])
  end
end

The run function of each command is passed a map of context which includes the current time t and a map of symbols symbols. The add_org commands creates a new org and binds it to the symbol org. Future commands can lookup this object as org. This eliminates the requirement to query in later commands which can simply get the org binding from the context symbols.

I’ve implemented about a dozen commands that represent key bits of functionality in our app like adding items, scoring, tagging, leaving comments and so on.

To run a script you use the DemoGen.Runner/run_demo/2 function:

def perform(%Oban.Job{args: %{"demo_name" => name, "demo_script" => script}}) do
    with path <- demo_file_path(name) do
      File.write!(path, script)
      Runner.run_demo(path, prefix: command_prefixes(), repo: Radar.Repo)
    end
  end

I use an Oban job to run mine, triggered from an admin page.

Hex.pm: DemoGen
Github: DemoGen

Under the hood this makes use of my parser combinator library Ergo, as well as Ecto, and Timex.

This has already saved me a huge amount of time and made my demos much more predictable. I hope it can be useful for you also.

https://github.com/AgendaScope/DemoGen

Most Liked

Lucassifoni

Lucassifoni

That looks awesome, thank you for sharing !

I lack time to re-implement my “demo mode” now that it exists but it would have benefitted a lot from that kind of tool. I’m forwarding it to a few bootstrapping acquaintances.

mattmower

mattmower

As an aside in my example of creating an Org I set a demo flag to be true. This is a field on the model. I also ensure that the delete_org command will only operate on org records where the demo flag is true to ensure that I cannot inadvertently delete some other organisation on the live server.

Also the commands are executed in a Repo transaction, if anything breaks you’re not left with a half-baked site. My understanding is that, for Postgres at least, this shouldn’t create a problem if you call code that also uses a transaction but I’m not clear about that. It hasn’t come up for us so far as I’m calling into context functions that would be wrapped in a transaction at a higher level.

It occurs to me that I could move the Repo.transaction out of DemoGen and allow the caller to wrap the call to the Runner.run_demo in a transaction themselves.

mattmower

mattmower

I’ve just released v0.1.8 which includes better documentation and the ability to pass relative datetime’s to commands.

My motivation was adding the scheduling of %Meeting{} schema objects to my demo since AgendaScope handles meetings now.

But a meeting is scheduled in the future, so I need a way to specify future dates & times relative to the demos’ concept of “now”. E.g. scheduled_at: demo_now+delta_time.

DemoGen manages “wall clock” time across the demo, passing the “current” time to each command as it runs and offering many ways of manipulating time.

Now this is now possible:

[10:00] add_meeting {
        as: meet_1
        group: leadership
        title: "Weekly Status Update"
        purpose: "Clarify priorities"
        scheduled_at: 🕒+7d-1h
}

Since the ‘demo time’ is 10:00 (which will be the inserted_at and updated_at value for the %Meeting{} schema) the the scheduled_at attribute will be set to 09:00 a week from current demo time.

In the command implementation, these time delta attributes get parsed into a function that can be used to modify a DateTime. For example, you might use it as follows:

{:rel_time, scheduled_fn} = Map.get(args, :scheduled_at)
scheduled_at = scheduled_fn.(t) # => T+7days-1hour
# Now pass `scheduled_at` as an attribute

Where t is the time value passed via the context to the command run function.

DemoGen has saved me hours of time, and severe frustration, by giving me consistently repeatable demo scenarios in our app at the click of a button.

Here is the implementation of add_meeting for our demo:

def run(args, %{time: t, symbols: symbols} = context) do
    group = Command.lookup_symbol(symbols, args, :group)
    creator = Command.lookup_symbol(symbols, args, :creator)

    {:symbol, as} = Map.get(args, :as)
    {:string, title} = Map.get(args, :name)
    {:string, purpose} = Map.get(args, :purpose)
    {:rel_time, scheduled_fn} = Map.get(args, :scheduled_at)
    {:integer, duration_mins} = Map.get(args, :duration)

    with {:ok, %Meeting{} = meeting} =
           add_meeting(t, creator, group, title, purpose, scheduled_fn.(t), duration_mins) do
      {:ok, %{context | symbols: Map.put(symbols, as, meeting)}}
    end
  end

Where Next?

Popular in Announcing Top

sorentwo
Hello! tl;dr Announcing Oban, an Ecto based job processing library with a focus on reliability and historical observability. After spen...
977 41022 311
New
Flo0807
Hello everyone! I am excited to share our heart project Backpex with you. After building several Phoenix applications, we realized that...
New
BartOtten
Phoenix Live Favicon Favicon manipulation for Phoenix Live A lib enabling dynamic favicons in Phoenix Live View applications. To sho...
New
pcharbon
This is a new extension for the Ash framework that lets you use the Commanded library in a more declarative manner and removes most of th...
New
mikehostetler
Hey everyone! I’m excited to share ReqLLM - a new approach to LLM interactions in Elixir that I’ve been working on. After building agent...
New
bradley
I’ve been working with Claude Code extensively and absolutely love it. However, I’ve come across the challenge of managing configuration ...
New
garrison
Hobbes is a scalable, fault-tolerant transactional record store written in Elixir. Hobbes is designed to be: Scalable - Hobbes can sha...
New
lostbean
Hello Elixir community! :wave: I’m excited to announce the first release of ReqCassette, a VCR-style record-and-replay library specifica...
New
leandrocp
MDEx is a fast and extensible Markdown parser and formatter. Fast Leverage Rust to parse, manipulate and render documents using: comra...
New
sodapopcan
I’ve heard that if you’re not embarrassed by v1 of your product Vim plugin then you’ve released too late. I’ve been sitting on this the ...
New

Other popular topics Top

shahryarjb
Hello, I get Persian date from my client and convert it to normal calendar like this: def jalali_string_to_miladi_english_number(persi...
New
aalberti333
As the title describes, I’m trying to run Enum.map() over a list of key/value pairs, where the value is a map. My data looks like this: ...
New
fireproofsocks
I’m working on defining a simple Ecto schema for a table (in PostGres), but I don’t see where I can define a column as NOT NULL. Conside...
New
chrismccord
This release brings a number of exciting features, including integration with the new Phoenix LiveDashboard and Phoenix LiveView. There h...
New
script
If I have a string “1000 cfu/ml” . I want to remove the characters and / and space . So the string is like this "1000" What is the ...
New
mcarvalho
What is the difference between System.get_env and Application.get_env? For example, what are best practices to use one versus another.
New
ashish173
I am using Ecto timestamps with postgres, I can see the timestamps() use the :naive_dateime but for my use case I wanted to store the ti...
New
alice
Hey, Just curious what are the main benefits of Elixir compared to Clojure? When is Elixir more useful than Clojure and vice versa? Th...
New
Fl4m3Ph03n1x
About me? ( if you have nothing better to do than reading about some random guy in the internet :stuck_out_tongue: ) Hello all, this is ...
New
siddhant3030
Hi, I have to write a raw query for one of my project. But till now I have used ecto queries and don’t have much experience writing raw ...
New

We're in Beta

About us Mission Statement