Better CLI
The better CLI puts a design system in your repository as files. You edit Liquid and JSON in your own editor, review changes in pull requests, and push a version to Better Email when it is ready.
Nothing about the platform changes underneath. The files are the same design system the Design System Editor shows, versions still land in the same timeline, and publishing to Live is still something a person does in the app. What moves is the authoring surface.
Install
npm install -g @better-email/cli
It needs Node 20.16 or newer on the 20.x line, or Node 22.3 or newer. The package bundles everything it uses and has no runtime dependencies. These pages describe 0.6.0.
better --help
Design system commands live under the ds noun — better ds pull, better ds push. Inside a design system directory the bare verbs work as shortcuts, the way git push needs no noun, so better check and better ds check are the same command. Only better login sits outside the noun.
Sign in
better login
This opens your browser, asks you to confirm a code, and stores the result in ~/.config/better-email/credentials.json with mode 600. The session is yours: every version you push is attributed to you in the design system's history, and your role in the workspace decides what you can do.
better check needs no credentials at all: it works offline, in any directory. better ds dev renders locally too, but it needs to know which Better Email to open the preview in, so sign in first or set BETTER_APP_URL.
For automation, use an organization API key instead of a browser session. See CI recipes.
Working in more than one organization
better whoami shows who you are signed in as and which organization the CLI is acting in; better org list shows every organization your account belongs to, and better org switch <org> moves the session to another one — no new browser sign-in needed.
Mostly you will not need to switch by hand. A directory bound by pull, bind, or push --create remembers its design system's organization, and every command run inside it switches to that organization automatically, printing one line when it does. A design system from another organization can never be touched by accident: the binding decides, not whatever you last signed in to.
API keys belong to a single organization and never switch. If a key's organization does not match the directory's binding, the command fails with a clear error instead.
Start a checkout
Which command you start with depends on whether the files or the design system came first.
| You have | Start with |
|---|---|
| Files, and no design system yet | better ds push --create |
| A design system, and no files yet | npm create better-email, or better ds pull <id> |
| Both — files here, a design system on the platform | better ds bind <id> |
From files: push --create
Clone a complete design system, then push it. That is the whole first run:
git clone https://github.com/betteremail/vandra.git acme-design-system
cd acme-design-system
better ds push --create --name "Acme Newsletter"
push --create runs the same local validation as better check, prints the diff, and asks before it touches your organization. Only after you confirm does it create the design system, bind this directory to it, and push version 1:
Summary: 12 added, 0 edited, 0 removed, Template Base changed.
Push this version? [y/N] y
Bound to Acme Newsletter. Binding tracks latest.
Created version 1: Acme Newsletter.
Not staged as Candidate: The Design System has no Live version to test against.
With --create, --name names both the design system and this first version. Leave it out and the design system takes the directory's name, while the version falls back to the usual timestamp and git short SHA. Leave out --create as well and push offers to create one anyway, asking for the name first:
This directory is not bound to a Design System. Create a new one and push? Name [acme-design-system]:
In CI there is nothing to answer, so a non-interactive --create requires --name, and an unbound directory without --create fails rather than creating anything.
From a design system: npm create better-email and pull
npm create better-email acme-design-system
It signs you in if you are not already, lists the design systems in your organization, pulls the one you choose, and adds an AGENTS.md describing the layout and the loop, a .gitignore, and a GitHub Actions workflow that runs better check on every pull request.
If you already have a repository, pull into it directly:
better ds list
better ds pull k97a1c8y3fq2m5r0n6w4hz7b
pull writes the managed files and records the binding in .better/config.json. Managed files that no longer exist on the platform are removed, so a module deleted in the app disappears from your tree. Everything outside the managed layout is preserved, and pull lists what it left alone.
By default pull takes the Live version. --channel candidate takes what is currently staged, and --channel latest takes the newest version whether or not it has been published. Pull latest when you are continuing work someone saved in the app; pull live when you want the tree campaigns are rendering right now.
Once a directory is bound, later pulls need no id — better ds pull refreshes the same design system. The channel does not carry over, though: a bare pull always takes Live, so repeat --channel when you want a different one.
A design system created in the app but never saved has nothing to pull. Rather than failing, pull binds the directory and tells you what to do next:
Acme Newsletter has no versions yet. Bound this directory — add your Design System files and run better ds push to create version 1.
Pull asks before overwriting
Pull replaces the managed files, so when the local tree already differs it shows what is about to go and waits:
Overwrite (2):
base.liquid
modules/hero/module.liquid
Delete (3):
modules/legacy_banner/module.json
modules/legacy_banner/module.liquid
modules/legacy_banner/settings.json
Continue with pull? [y/N]
Long lists are cut off after ten paths with a count of the rest. --yes answers in advance, which is what a script wants; without it and without a terminal to ask, pull cancels and exits 1 rather than guessing.
From both: bind
better ds bind k97a1c8y3fq2m5r0n6w4hz7b
bind records the binding and touches nothing else — no files are written, overwritten, or deleted. Use it when the files you want are the ones already in this directory: a clone of Vandra, an adapter's output tree, a design system you have been keeping in git.
It binds to Live when there is a Live version and to latest when there is not; --channel chooses explicitly. Because nothing was pulled, the binding has no baseline, and the CLI is straightforward about the consequences:
Bound to Acme Newsletter. better ds diff compares against live; pushing will require --force if the Design System already has versions.
Until you pull or push, better ds diff reports every module as added and better ds status says Pulled version: no versions pulled yet. A first push over a design system that already has versions needs --force, because those versions are work this checkout has never seen. One directory binds once: to point it at a different design system, delete .better/config.json deliberately.
Starting from Vandra
betteremail/vandra is a complete, MIT-licensed design system kept in this format: the full email program of Vandra, a fictional Norwegian hiking-gear brand — eight modules, one template base with the header and footer built in, and one set of brand tokens. Not a scaffold but an example of the finished thing; it is the fastest way to a working tree, and the reference for how the files fit together.
Vandra is a set of files rather than a hosted template. For a new design system, clone it and run better ds push --create as above. To adopt it into a design system you already own, copy it over a pulled tree so you can see the change first:
git clone https://github.com/betteremail/vandra.git
better ds pull <id> --dir my-design-system
cp -R vandra/. my-design-system/
cd my-design-system && better check
Copying over a pulled tree keeps .better/ and anything unmanaged, so the checkout stays bound and better ds diff shows exactly what adopting Vandra would change before you push it.
Already have a build?
If your emails are built with Maizzle or React Email, do not start from a checkout at all: keep the project you have and generate the design system tree out of it, so your components and git history come with you. Each adapter has its own worked example repository.
| Starting point | Repository | For |
|---|---|---|
| Hand-authored files | betteremail/vandra | A complete design system, and the reference for the format. |
| Maizzle project | betteremail/maizzle-starter | Tailwind fragments with a better: front-matter block. See migrating from Maizzle. |
| React Email project | betteremail/react-email-starter | React components with a typed better export. See migrating from React Email. |
All three are MIT licensed.
The loop
pull → edit → check → dev → diff → push
check
better check
Validates the local tree: file integrity, the JSON schemas, setting-key uniqueness, and module order. It needs no credentials and no binding, so it runs anywhere — a pre-commit hook, a pull request, a bare clone.
Errors name the file and say what to do:
Local checks only; the server validates the Design System again on push.
Duplicate setting key "hero":
modules/hero/settings.json
modules/feature_row/settings.json
Exit code 0 means the tree is valid, 1 means it is not.
Some things are valid and still wrong, and those come back as warnings. A colour input with no options and allowCustomColor off is the one to know: the schema allows it, but nobody in the Campaign Editor can edit it.
modules/hero/settings.json: Color input "accent" has no options and custom colors are off, so nobody can edit it. Add options or set allowCustomColor.
Warnings never fail the run. The exit code stays 0, ok stays true in --json, and the push goes through — the check is telling you the input is dead, not stopping you.
--json prints one result object instead, for tools that need to read the findings rather than show them:
{"ok":false,"findings":[{"path":"modules/hero/settings.json","message":"Duplicate setting key \"hero\".","severity":"error"}]}
Each finding carries path, message, severity — error or warning — and a line when the checker knows one. This is what the VS Code extension turns into inline diagnostics.
dev
better ds dev
Watches the managed files and serves a live preview with editable inputs. Save a file and the preview updates.
Open in the app: https://app.better.email/design-systems/local-dev?server=4100
Watching managed files. Press Ctrl+C to stop.
That one URL opens your local server inside Better Email, so you get the real module switcher and settings sidebar around your local files. Rendering still happens entirely on your machine, and your files are not uploaded to do it: the app page reads them from localhost in your own browser. Opening it needs an Admin or Designer role in the app.
The preview therefore needs to know which Better Email to open. better login supplies that; BETTER_APP_URL overrides it for a local or staging environment. With neither, dev says so and stops:
No app origin available for the preview. Run better login, or set BETTER_APP_URL for local/staging.
The default port is 4100. If it is busy the CLI slides to the next free port and says which one it took, so two previews can run side by side; when the first ten are all taken it lets the operating system pick one. An explicit --port fails instead of sliding. --open launches the browser for you.
If a module renders as nothing here and better check is happy, look at its first and last Liquid tags: whitespace control at either boundary makes a module vanish silently. See whitespace control at the module boundary.
diff
better ds diff
Compares the local tree with the channel you pulled, module by module:
+ added Seasonal Offer (cmp_seasonal_offer)
~ edited Hero (cmp_hero) [content, settings]
- removed Legacy Banner (cmp_legacy_banner)
~ Template Base: Base HTML, Content zones
~ Module order changed
Module rows are flagged with what changed — content, settings, behavior. The template base row names the parts that differ: Base HTML, Global settings, Content zones, Assets, Global values, or Metadata.
--against live|candidate|latest compares with a different channel. Exit code 1 means there are changes, 0 means there are none, which makes diff usable as a drift check in CI.
In a directory bound with better ds bind, or bound by pulling a design system that had no versions, there is nothing to compare against yet, so every module reads as added and the template base as changed. That is the honest answer: a push from here writes the whole tree.
push
better ds push --name "Summer refresh"
Push prints the same diff, asks for confirmation in a terminal, and creates a version in the design system's history — a numbered, named entry, exactly like saving a version in the editor. Without --name, the CLI names it after the timestamp and the current git short SHA. --description adds a longer note.
In an unbound directory, --create makes the design system first and binds the directory to it. See push --create.
If the organization has candidate testing enabled and the design system has a Live version to test against, push also stages the new version as the Candidate:
Created version 12: Summer refresh.
Staged as Candidate.
Otherwise it says why it did not:
Created version 12: Summer refresh.
Not staged as Candidate: Candidate testing is off for this Organization.
--no-stage creates the version without staging it. --yes skips the confirmation prompt.
The furthest a push can go is the Candidate slot. Making a version Live is a deliberate action taken by an Admin or Designer in the app, from the design system's version timeline. A merged pull request therefore cannot change what campaigns render.
Push is guarded against overwriting work you have not seen. It records which version was newest when you pulled, and refuses if the design system has moved on since:
This Design System changed since your last pull. Run `better ds pull` (or push --force).
It also refuses while someone holds the editor lock in the app, and reports that person by name. An unchanged tree pushes nothing at all.
status
better ds status
Design System: Acme Newsletter (k97a1c8y3fq2m5r0n6w4hz7b)
Channel: live
Pulled version: k1739fdz2xy8p4c0v6s3jr5q
Live: version 11 — Spring layout
Candidate: none
Latest: version 11 — Spring layout
Local tree: clean
Local tree: modified means your files differ from what you pulled. The three pointers are the platform's current view, so status is how you notice that someone published or staged something while you were working.
A directory bound without pulling has no version to compare against, so it reports Pulled version: no versions pulled yet, and Local tree: empty until you put files in it.
A full pass
Adding a testimonial module to a design system that has candidate testing on.
better ds pull k97a1c8y3fq2m5r0n6w4hz7b
git switch -c testimonial-module
Create modules/testimonial/ with its three files, then add testimonial to modules.order. Validate before rendering anything:
better check
Local checks only; the server validates the Design System again on push.
modules/testimonial: Add "testimonial" to modules.order; this module directory is unlisted.
Fix modules.order, run better check again, then open the preview and iterate on the Liquid:
better ds dev --open
When it looks right, confirm what you are about to send:
better ds diff
+ added Testimonial (cmp_testimonial)
~ Module order changed
Open a pull request. The check job runs better check against the branch; reviewers read the Liquid and the settings JSON as a normal diff. After merge:
better ds push --name "Testimonial module"
+ added Testimonial (cmp_testimonial)
~ Module order changed
Summary: 1 added, 0 edited, 0 removed, Module order changed.
Push this version? [y/N] y
Created version 12: Testimonial module.
Staged as Candidate.
The module is now testable. Someone creates a campaign on the Candidate channel, checks the module in a real inbox, and publishes version 12 from the app when it holds up. See candidate testing for the whole staging and publishing flow.
Manage in code
Once a design system's structure lives in a repository, an edit made in the app is a change nobody reviewed, and it puts the two out of sync: your next push is refused until you either pull it down or force past it. Turn on Manage in code in the design system's settings to stop that happening.
While it is on:
- the in-app Module and template base editor is view-only, and Betty's design system chat is unavailable
- the CLI and the public API are the only way to change modules, settings, and the template base
- publishing a pushed version to Live stays a human action in the app, for Admins and Designers
- campaign editing is completely unaffected
The editor shows an amber banner explaining why it is read-only. Turning the setting on requires an Admin, and is refused while someone holds a live editor seat, so nobody loses an unsaved draft.
Editor support
Every JSON file the CLI writes carries a $schema key, so any editor with JSON Schema support gives you completion, hover documentation, and inline validation with no configuration.
The Better Email Design Systems extension for VS Code adds what a schema cannot:
- Liquid autocomplete. Typing in
module.liquidcompletes the module's own settings unprefixed,template.*andglobal.*from the design system, and thecontext.*runtime values. Repeatable settings complete through the activeforortablerowloop variable, and image and link inputs offer the same sub-properties as the web editor. Hovering an input reference shows its name, type, and default value. Unsavedsettings.jsonbuffers win over the file on disk, so completion follows an input you just added. - Inline diagnostics. Saving a managed file runs
better ds check --jsonand surfaces the findings in the Problems panel. - A module list in the activity bar, in
modules.order, for jumping between modules. - Start and stop the local preview — Better Email: Start Local Preview runs
better ds devand opens the app-hosted preview, showing unsaved edits as you type. It opens in your system browser rather than an editor tab, because the app's sign-in cookies do not reach a VS Code webview.
Point betterEmail.cliPath at your better binary if it is not on the extension's PATH.
Command reference
| Command | What it does |
|---|---|
better login [--base-url URL] [--api-key KEY] | Sign in through the browser, or store an organization API key. |
better whoami | Show the signed-in identity and current organization. |
better org list | List your organizations; better org switch <org> changes the current one. |
better ds check [--json] | Validate the local tree. No credentials, no binding. Also better check. |
better ai-files install [--check] | Write or refresh the agent teaching files. See working with AI agents. |
better ds list | List the organization's design systems with their Live, Candidate, and Latest version numbers. |
better ds bind <ID> [--channel live|candidate|latest] | Bind the directory without pulling or changing any file. |
better ds pull [ID] [--channel live|candidate|latest] [--dir DIR] [--yes] | Write a design system to files and bind the directory. --yes skips the overwrite confirmation. |
better ds dev [--port N] [--open] | Local live preview with editable inputs, opened in the app. |
better ds diff [--against live|candidate|latest] [--format text|markdown|json] | Compare the local tree with a channel. Exit 1 on changes. |
better ds push [--create] [--name NAME] [--description TEXT] [--no-stage] [--force] [--yes] | Create a version, staged as Candidate when allowed. --create makes the design system first. |
better ds status | Binding, remote version pointers, and clean or modified local state. |