A CLI for things that aren't apps
Backups, Makefiles and cron jobs will never import an SDK. The butters CLI sends events, metadata and KPI numbers to Butters from anywhere you can run a command.
Most event tools assume you're inside an app. You install an SDK, import it, and call track() from your code.
A lot of what's worth knowing about doesn't happen in an app. It happens in a shell script that rotates backups, a Makefile target that builds a release, a one-off migration someone runs from their laptop, a cron job on a box nobody logs into. None of those will ever import an SDK.
That's what butters is for. It's one binary, with no runtime to install, that sends events to Butters from anywhere you can run a command. This post covers installing it, the handful of commands you'll use, and the gaps worth knowing about before you lean on it.
Installing it
The CLI lives in its own repo, getbutters-cli . The quickest install is the script:
curl -fsSL https://raw.githubusercontent.com/freekrai/getbutters-cli/main/scripts/install.sh | bash It picks the right binary for your platform, checks it against the release's SHA256SUMS, and puts it in ~/.local/bin. If you'd rather not pipe a script into a shell, download the binary yourself from the latest release . There are builds for macOS (Apple silicon and Intel), Linux (x64 and arm64) and Windows.
One macOS note: the binaries are unsigned. A binary fetched with curl runs as is, but one downloaded through a browser may be blocked by Gatekeeper. Clear it by running xattr -d com.apple.quarantine on the file you downloaded, for example xattr -d com.apple.quarantine ./butters.
Next, a key. Create one on the API page in the app (/app/api, then New key). It's only shown once, so copy it before you navigate away:
export GETBUTTERS_API_KEY=ev_...
butters list butters list prints your projects, one <id> <name> per line, or no projects yet. If that works, you're set up.
The key decides which organization every command acts on. There's no login and no config file. You can also pass --api-key on a single command, which beats the environment variable. If you self-host, add --url http://your-server to point it somewhere other than https://app.getbutters.com.
It's just an API client
The CLI never touches a database. Every command goes through the same authenticated HTTP API an outside integration would use, with the same organization scoping. So the server has to be reachable for every command, including backups.
That also makes it predictable in scripts. An unreachable server is reported as such, not as a stack trace. API errors come back as error: <status>: <message>. And every failure exits non-zero, so set -e and || work the way you'd expect.
Creating a project
butters init --name "infra"
# created infra
# id: q4q8nb18qc2i The id is what every other command means by --project. In a setup script you can capture it directly:
PROJECT=$(butters init --name "infra" | awk '/id:/ {print $2}') Pushing events
push needs three things: a project, a category, and a title. Categories are created the first time you use them.
butters push \
--project q4q8nb18qc2i \
--category backups \
--title "Nightly backup finished" \
--icon "💾" The optional flags cover the rest of an event: --description (which renders **bold** and [links](url)), --link for the URL the event opens, --user-id for an external user, and --notify.
Two of these are easy to trip on. The link flag is --link, not --url, because --url already means the server's address. And --notify does more than highlight the event: it sends it to every notification destination on the project, which means every ntfy topic, outgoing webhook and push-enabled device ( Send events in, send them out covers the last two). Use it the way the notify post describes, for things someone needs to act on soon.
When a script fails, send the evidence
The flag worth learning is --metadata. It takes a JSON object or array, and the feed opens it in a dialog. For a failing script, that's where the exit code and the last few lines of output go:
if ! ./backup.sh > /tmp/backup.log 2>&1; then
butters push \
--project q4q8nb18qc2i \
--category backups \
--title "Nightly backup failed" \
--icon "🔥" \
--notify \
--metadata "$(jq -n --arg tail "$(tail -n 20 /tmp/backup.log)" \
'{job: "backup", exit: 1, tail: ($tail | split("\n"))}')"
fi The CLI parses the JSON before sending, so a broken quote is reported on your terminal rather than as a 400 from the server. If you write the JSON by hand instead of with jq, single-quote the whole document so the double quotes inside it survive the shell.
The pattern around it, which jobs to wrap and how to report success without drowning in it, is in Deploys, cron jobs, and the things that fail quietly . The CLI is just a shorter way to write the same thing.
Keeping a number current
Events are for things that happened. Some things are better as a number: sales in the last 24 hours, orders waiting to ship, the size of the backup bucket. That's what insight cards are for, and insight sets one:
butters insight \
--project q4q8nb18qc2i \
--title "24h Sales" \
--value '$1,449' \
--icon "☀️" The title is the card's key within the project. Run the same command tomorrow with a new value and it updates the same card instead of making a second one. So a nightly job can compute a number and set it, and the dashboard always shows the latest:
# crontab: 6am daily
0 6 * * * butters insight --project q4q8nb18qc2i --title "Orders Processing" --value "$(./count-open-orders.sh)" --icon "🏭" Single-quote any value with a $ in it, or the shell will expand it before butters ever sees it.
Backups and moving data
export writes your organization's data to a JSON file:
butters export --file "backups/$(date +%F).json" load reads a file back in. It's worth being clear about what it does: it only merges, never replaces. Every project it loads gets a fresh id, so loading the same file twice gives you two independent copies rather than overwriting the first. It prints a was -> now id mapping, which is the only record of what the old ids became.
So load is the right tool for moving data into a new organization or a self-hosted server. It isn't a restore button for a project that already exists.
Demo data in ten seconds
The CLI repo ships a set of demo scenarios, each 30 days of realistic events in exactly the shape load accepts. They're useful for seeing what a busy feed looks like before your own traffic arrives. They aren't bundled into the binary, so fetch the one you want:
curl -fLO https://raw.githubusercontent.com/freekrai/getbutters-cli/main/demos/saas.json
butters load --file saas.json That one is Countersign, a made-up e-signature SaaS with trials, billing, signed documents and integrations. There's also a micro-bakery, an indie game launch, a homelab and a greenhouse full of sensors.
One caution: loaded events count against your plan's monthly allowance, and a single scenario has more events than the free tier allows. An import that goes over the limit imports nothing, so you won't end up with half a demo, but on the free plan it will be refused. Try the demos on a paid plan or a self-hosted server.
What it doesn't do yet
A few honest gaps:
- No tags flag.
pushhas no--tagsyet, so tags can only be set through the API for now.--metadatacovers most of what you'd use them for in scripts. - No way to delete an insight card. The API deletes insights by id, and nothing returns those ids yet. Remove a card from the dashboard.
- No restore in place. As above,
loadalways creates new projects.
Where to start
Pick the one script that would hurt most if it failed silently. For most teams that's the backup. Install butters, wrap that script with a push on failure (with --metadata and --notify) and a quiet push on success. Then add one insight for the number you check most often by hand.
That's three commands, and the next time something breaks at 3am, the feed already has the log tail waiting for you.