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.