Skip to content

What perch is for

This is the long-form answer to "why would I use this?" Each section below is a real category of work that perch is good at, with a concrete sketch and an honest take on why it beats the alternatives. There's also a section at the end on when not to use perch.

If you only have 30 seconds: perch is good wherever you'd otherwise hand-roll a CLI with subcommands, a Makefile, a bash script, or a YAML-driven runner β€” and you want it to work on macOS / Linux / Windows out of the box, and you'd like to ship the result as a single binary, web UI, or MCP server without writing extra code.


The two leverage points that recur

Before the application catalog, two perch capabilities show up in almost every use case. Worth holding in your head.

1. One file β†’ four frontends. A single commands.perch is callable as:

  • a CLI (perch <cmd> or your-named-binary after --build)
  • a web UI (perch --server)
  • a REPL (perch --shell)
  • an MCP tool surface for AI agents (perch-mcp)

You write the file once. Which frontend matters depends on who's using it: developers want the CLI, operators want the web UI, AI agents want MCP. Same source.

2. perch --build produces a self-contained binary. The output is a fat binary with your program JSON appended; no Go toolchain or perch install needed on the recipient's machine. This collapses the "make a CLI tool" problem from "write a Cobra app, set up packaging, write a release pipeline" down to a single command.

Most applications below lean on one or both of these.

Third recurring property: HTTP is hardened by default. Every application that uses http_get, http_post, download, or http_status inherits perch's default-on protections β€” no SSRF to AWS metadata or local services, no https β†’ http redirect downgrade, max 5 redirect hops, DNS-rebinding defense (multi-A check). Layer --allow-host api.github.com,*.docker.io,... to pin which hosts the script can reach (initial URLs AND every redirect target re-validated). Critical for the AI-agent applications below where the agent picks the URL. See sandbox Β§0c.


Application catalog

1. Replace your Makefile

The base case. Every make target: becomes a command NAME ... end. Cross-cutting variables are declared bare at the top level (NAME = value). The if os == "darwin" form covers the platform-conditional code you'd normally write with three Makefiles.

BIN_DIR  = "./bin"
APP_NAME = "myapp"

command build
    arg target
        type string
        default "darwin"
        description "GOOS to build for"
    end
    do
        mkdir "${BIN_DIR}/${target}"
        with_env "GOOS=${target}"
            go build -o ${BIN_DIR}/${target}/${APP_NAME} ./cmd/${APP_NAME}
        end
    end
end

command test
    description "Run tests"
    do
        go test -race ./...
    end
end

command ci
    description "Lint + test + release"
    do
        test
        go vet ./...
    end
end

Why perch wins: Make doesn't work on Windows. make ci requires you to call go vet with the right quoting in three different OSes. perch's ops are first-class and cross-platform. --help is auto-generated and your commands.perch is also a CI script β€” run: perch ci in .github/workflows/ci.yml is the whole job definition.

β†’ Worked example: demos/03-go-project and tutorials/01-replace-your-makefile.


2. Ship an internal team CLI as one binary

Your team has a folder of bash scripts: bin/backup, bin/restart-api, bin/check-disk. Half the team has the folder cloned, half doesn't. Onboarding takes 20 minutes of "git clone, add to PATH, sudo this, run that."

Fold the whole thing into one commands.perch, then:

perch --build -o ops
scp ops bastion:/usr/local/bin/

The recipient runs ./ops backup immediately. No git clone. No Python venv. No node_modules.

name    "ops"
about   "Platform team operations CLI"
version "1.0.0"

command backup
    description "Snapshot the primary DB to S3"
    do
        stamp = now "unix"
        shell "pg_dump -h db-primary.internal -Fc > /tmp/db-${stamp}.dump"
        aws s3 cp /tmp/db-${stamp}.dump s3://backups/db-${stamp}.dump
        rm "/tmp/db-${stamp}.dump"
    end
end

command restart_api
    description "Roll-restart the api deployment"
    do
        kubectl rollout restart deployment/api -n prod
        kubectl rollout status deployment/api -n prod
    end
end

catch unknown
    do
        print "ops doesn't know '${unknown}'."
        print "Try: ops backup | ops restart_api"
        exit 1
    end
end

Why perch wins: every alternative requires more steps. Cobra requires you to write argument parsing, help text, and a release pipeline. A bash distribution requires the recipient to install at least bash + the same set of dependencies you used. Click/Python requires Python on the host.

β†’ Worked example: tutorials/02-ship-a-tool.


3. Wrap a clunky CLI as a friendly tool

Probably the highest-leverage application β€” and the one most teams have a use for.

Many tools have a good engine, bad UX. Docker is the canonical example: enormously powerful, but the day-to-day flow ("pull the image, run with these mounts and ports, stream logs, stop, clean up") requires memorising six different invocations. Same story for ffmpeg, kubectl, AWS CLI, openssl, rsync, gh, tar, git β€” every team has a handful of "I always have to look up the flags" tools.

perch lets you wrap any of these with a sane verb-driven CLI in 30 lines. The shippable binary doesn't require the recipient to learn the underlying tool at all.

Concrete example β€” a friendly Redis-via-Docker wrapper:

name    "redis"
about   "Friendly wrapper around the official Redis Docker image"
version "0.1.0"

IMAGE     = "redis:7-alpine"
CONTAINER = "my-redis"

requires
    bin "docker"
end

command install
    description "Pull the Redis Docker image"
    do
        docker pull ${IMAGE}
    end
end

command run
    description "Start Redis in the background on the host port"
    arg port
        type int
        default 6379
        description "Host port to expose Redis on"
    end
    do
        docker run -d --rm --name ${CONTAINER} -p ${port}:6379 ${IMAGE}
        print "Redis running on port ${port}. Use 'redis cli' to connect."
    end
end

command cli
    description "Open a redis-cli session into the running container"
    do
        docker exec -it ${CONTAINER} redis-cli
    end
end

command logs
    description "Stream container logs"
    do
        docker logs -f ${CONTAINER}
    end
end

command status
    description "Is Redis running?"
    do
        docker ps --filter name=${CONTAINER}
    end
end

command stop
    description "Stop the running container"
    do
        docker stop ${CONTAINER}
    end
end

command uninstall
    description "Stop and remove the image"
    do
        stop
        docker rmi ${IMAGE}
    end
end

Then:

perch --build -o redis
./redis install
./redis run -port=6380
./redis cli
./redis logs
./redis stop
./redis uninstall

The recipient never types docker once. ./redis --help is the discoverable command list. ./redis run --help shows the typed arg with its default.

Pair it with --server and someone non-technical clicks buttons:

./redis --server --port 8080

A designer who wants Redis locally for testing now opens a browser, clicks "run", and never thinks about Docker. The wrapper is the user-facing interface; the underlying tool is an implementation detail.

Pair it with MCP and an AI agent uses the same surface:

{
  "mcpServers": {
    "redis": { "command": "perch-mcp", "args": ["-f", "/abs/redis.perch"] }
  }
}

The agent sees redis_install / redis_run / redis_cli / redis_stop β€” semantic verbs, not Docker syntax. It can reliably orchestrate complex sequences without learning Docker.

Other "good engine, bad UX" candidates where this pattern shines:

Underlying tool What you wrap Example commands
Docker / Podman Image lifecycle for a specific service install / run / cli / logs / stop / uninstall
kubectl Your team's deploy / rollback / status flows deploy / rollback / status / logs / exec
ffmpeg Specific recipes you actually use to_mp4 / extract_audio / make_gif / compress / concat
AWS CLI Operations your team does list_instances / rotate_key / fetch_logs / tail_cloudwatch
gh (GitHub) Team-specific workflows start_feature / open_pr / rebase_main / release_notes
openssl Cert lifecycle generate_cert / verify_cert / convert_pem / inspect
rsync Curated backup / sync recipes backup_docs / sync_dotfiles / mirror
tar / unzip Project-specific archive flows pack_release / unpack_release
git Internal workflow conventions start_feature / ship / fixup / cleanup_branches
terraform Plan/apply/destroy with team guardrails plan_staging / apply_staging / destroy_staging
psql / pg_dump DB ops db_backup / db_restore / db_psql / db_migrate
gpg Encrypt/decrypt with sane defaults encrypt_for_team / decrypt / verify_signature
systemctl / launchctl Service management for your services start / stop / restart / tail_logs

Every one of these is something a team has a "wiki page of incantations" for. Each wiki page β†’ a commands.perch β†’ a shippable binary that turns the wiki page into a discoverable, type-safe CLI.

Why perch wins for this case specifically:

  1. The wrapper is 30 lines, not a 500-line Cobra app. No flag-parsing scaffolding, no --help boilerplate.
  2. It ships as one binary. The recipient doesn't install perch, doesn't need Go, doesn't need the recipe file.
  3. Three frontends from one source. CLI for developers, web UI for non-engineers, MCP for AI agents. You write the recipe once.
  4. --check keeps the wrapper honest. If you typo a flag in docker run -i …, perch --check won't catch the typo (it's inside a shell string), but typo'd command refs, missing args, broken command invocations all surface before users hit them.
  5. --help is auto-generated from the description fields, with typed args and defaults. No "documentation drift."

This is the application that most teams will discover first. Wrapping a known-clunky tool is the gateway use case; from there, the same team finds they can do all the other applications below.


4. Extend an existing tool with team conventions (catch passthrough)

A close cousin of section 3, but a distinct pattern with its own use case. Instead of replacing a tool's UX, you extend it: add your team's high-value shortcuts on top while still letting users (and muscle memory) reach the underlying tool for anything else.

Inside catch, declare the proxy_args modifier to bind the full unknown invocation as ${proxy_args}. Without the modifier, ${proxy_args} is unbound β€” the forwarding pattern has to be explicit in the source. Forward it to the real binary and you have a drop-in superset.

name "git"

command ship
    description "Commit all changes, push HEAD, open a PR"
    arg msg
        type string
        description "Commit message"
    end
    do
        git add -A
        git commit -m ${msg}
        git push -u origin HEAD
        gh pr create --fill
    end
end

command fixup
    description "Amend the most recent commit with current changes"
    do
        git add -A
        git commit --amend --no-edit
    end
end

command cleanup
    description "Delete branches already merged into main"
    do
        shell "git branch --merged main | grep -v '^[* ]*main$' | xargs -r git branch -d"
    end
end

catch passthrough
    description "Forward unknown commands to real git"
    proxy_args                       # required to bind ${proxy_args}
    do
        shell "git ${proxy_args}"
    end
end

Then perch --build -o git-team:

Invocation What happens
git-team ship -msg="fix bug" Your custom multi-step workflow
git-team fixup Amend + no-edit
git-team cleanup Prune merged branches
git-team status Falls through to real git status
git-team log --oneline -10 Falls through; args preserved
git-team rebase -i HEAD~3 Falls through

Why perch wins: you get a superset of the underlying tool, not a replacement. Users keep their muscle memory; your team's conventions become first-class. --help lists only the additions, so people discover the new commands; familiar commands still work without docs.

Other tools that benefit from extension over replacement:

  • kubectl β€” add kc deploy <svc>, kc rollback <svc>, kc tail <svc>; passthrough for kc get pods, kc describe, etc.
  • terraform β€” add tf plan-staging, tf apply-staging, tf destroy-staging with safety guards; passthrough for tf init, tf fmt.
  • docker β€” add d up, d down, d shell; passthrough for d ps, d inspect.
  • npm β€” add n release, n bump-major, n publish-canary; passthrough for n install, n run.

5. LLM-safe wrapper around dangerous tools

When you give an AI agent access to a powerful tool, the grammar perch enforces becomes the security boundary. Instead of letting Claude / Cursor / Zed call kubectl or aws or terraform directly via a shell tool β€” where any prompt injection could cause arbitrary damage β€” you expose only the operations you've vetted, with explicit confirmation tokens for destructive ones.

name "prod-kubectl"
about "AI-safe surface for production kubectl ops"

# ── Read-only ops β€” agents can call these freely ───────────────────

command get_pods
    description "List pods in prod"
    do
        kubectl get pods -n prod
    end
end

command logs
    description "Tail recent logs of one pod"
    arg pod
        type string
        description "Pod name"
    end
    arg lines
        type int
        default 100
        description "How many lines"
    end
    do
        kubectl logs -n prod ${pod} --tail=${lines}
    end
end

command status
    description "Cluster snapshot"
    do
        kubectl get all -n prod
    end
end

# ── Mutating ops β€” require an explicit confirmation token ─────────

command restart_api
    description "Roll-restart the api deployment (mutating)"
    arg confirm
        type string
        description "Must be 'YES' β€” proves the agent meant to mutate"
    end
    do
        if confirm != "YES"
            fail "restart_api requires -confirm=YES (got: '${confirm}')"
        end
        print "Restarting api in prod…"
        kubectl rollout restart deployment/api -n prod
        kubectl rollout status deployment/api -n prod
    end
end

# ── No catch-all β€” anything else is rejected hard ─────────────────

Wire it into the MCP client:

{
  "mcpServers": {
    "prod-k8s": {
      "command": "perch-mcp",
      "args": ["-f", "/abs/prod-kubectl.perch"]
    }
  }
}

The agent sees get_pods, logs, status, restart_api. It cannot call kubectl delete pod, kubectl drain node, kubectl scale --replicas=0. The grammar is exhaustive: every callable operation is declared.

Multi-layer safety the wrapper enforces:

  1. Whitelist by declaration. Unlisted operations are unreachable. No catch-all, no shell escape.
  2. Typed args. arg lines int default 100 β€” the agent can't sneak in a ; rm -rf / (the value goes into a structured op, not a shell string).
  3. Confirmation tokens. Mutating ops require -confirm=YES (or "TYPED_OUT_REASON" for stricter cases). The agent must produce the literal string, which means the LLM has to "intend" the mutation.
  4. Audit log for free. perch --server streams every op as NDJSON. Pipe to your observability stack and you have a complete audit trail of every agent action.
  5. --check keeps the policy honest. As you add ops, the validator catches typo'd arg references / unresolved placeholders / unreachable branches before they ship.

Compare to giving the agent raw kubectl: prompt injection or hallucination β†’ cluster damage. With perch's curated surface, the worst the agent can do is something you decided was safe to expose.

Same pattern for other "dangerous engine" tools:

  • rm / file-system ops β€” only the deletes you've vetted; everything else absent
  • terraform apply β€” only against pre-named environments; only after terraform plan (encoded as a bare plan_first invocation)
  • aws / cloud APIs β€” only the read/write surfaces your security team approves
  • DB clients β€” only the queries you've parameterised; no raw SQL

The grammar is the policy. The schema is the boundary.


6. Unify multiple binaries under one tool

Your dev environment depends on 10 tools: node, postgres, redis, kubectl, helm, terraform, jq, ripgrep, gh, docker. New hires spend half a day installing them, often wrong, with version drift. Status checks ("which version of helm do I have?") are a folder full of one-liners.

A single perch binary becomes your team's meta-tool:

name "devtools"
about "Install / status / update the team's dev toolkit"
version "0.1.0"

NODE_VER  = "20"
PG_VER    = "15"
HELM_VER  = "3.14"
TF_VER    = "1.7"

command install_all
    description "Install everything"
    do
        install_node
        install_postgres
        install_redis
        install_kubectl
        install_helm
        install_terraform
        install_jq
        install_ripgrep
        install_gh
        install_docker
        print "All tools installed."
    end
end

command install_node
    do
        if exists "/usr/local/bin/node"
            print "node already installed"
        end
        if not exists "/usr/local/bin/node"
            if os == "darwin"
                brew install node@${NODE_VER}
            end
            if os == "linux"
                shell "curl -fsSL https://deb.nodesource.com/setup_${NODE_VER}.x | sudo bash -"
                sudo apt-get install -y nodejs
            end
        end
    end
end

command install_postgres
    do
        if os == "darwin"
            brew install postgresql@${PG_VER}
        end
        if os == "linux"
            sudo apt-get install -y postgresql-${PG_VER}
        end
    end
end

# … one install command per tool …

command status
    description "What's installed, what's missing"
    do
        n  = exists "/usr/local/bin/node"
        pg = exists "/usr/local/bin/psql"
        r  = exists "/usr/local/bin/redis-cli"
        k  = exists "/usr/local/bin/kubectl"
        h  = exists "/usr/local/bin/helm"
        tf = exists "/usr/local/bin/terraform"
        print "node:        ${n}"
        print "postgres:    ${pg}"
        print "redis:       ${r}"
        print "kubectl:     ${k}"
        print "helm:        ${h}"
        print "terraform:   ${tf}"
    end
end

command update_all
    description "Re-run installers; package managers handle the upgrade"
    do
        install_all
    end
end

command uninstall_all
    description "Remove everything (irreversible β€” asks for confirm)"
    arg confirm
        type string
        description "Must be YES"
    end
    do
        if confirm != "YES"
            fail "Pass -confirm=YES to actually uninstall"
        end
        if os == "darwin"
            brew uninstall node@${NODE_VER} postgresql@${PG_VER} redis kubectl helm terraform jq ripgrep gh
        end
        if os == "linux"
            sudo apt-get remove -y nodejs postgresql-${PG_VER} redis kubectl jq ripgrep gh
        end
    end
end

perch --build -o devtools and now your team's dev-environment setup, status, update, and teardown all live behind one binary:

./devtools install_all       # one-time onboarding
./devtools status            # which tools are present?
./devtools install_helm      # add one missing tool
./devtools update_all        # bump everything
./devtools uninstall_all -confirm=YES   # before reimaging the machine

Why perch wins: the alternative is an install.sh per tool, a README#installation section that lies, and a Slack channel where people ask "what version of helm do you have?" The unified binary collapses all of that into one auditable source.

Add --server and your laptop-provisioning team gets a web UI. Add MCP and the AI agent helping a new hire can install missing tools on demand.

Variations of "multi-binary unifier":

  • Polyglot language manager β€” wraps asdf / mise / pyenv / nvm / rustup under one set of commands (langs install node 20, langs use python 3.11).
  • Cloud provider unifier β€” same verb-set across AWS/GCP/Azure (cloud list-instances, cloud start-vm).
  • Service stack starter β€” stack up brings up postgres + redis + the app, with if exists guards for already-running services.
  • Backup destination router β€” backup s3, backup b2, backup rsync β€” same recipe, different targets.
  • Editor/IDE plugin manager β€” install / update / list plugins for vim, vscode, neovim from one CLI.

7. Distribute a Python / JS / non-native project as one binary

This is genuinely a new packaging model. The user pays no Python/Node/Ruby tax: they download one file, run one command, and your project is installed.

perch --build --include <path> embeds an arbitrary file tree inside the produced binary as a gzipped tarball. At runtime, three ops give your install command access to the archive:

Op Returns Use
bundle_hash string SHA-256 of the embedded archive β€” the canonical "version" id
bundle_extract DST string Extract to DST (idempotent)
bundle_dir string Lazy-extract to an OS temp dir; cached for the process lifetime

The pattern: extract the project to $HOME/.cache/perch/<tool>/<hash>/, run its native install (venv + pip, npm, gem, build), drop a launcher into $HOME/.local/bin/<tool>. New hash β†’ new install dir, old versions coexist, pruning is rm -rf.

Worked example β€” stt_bin, a single binary that installs a Python project:

name    "stt_bin"
about   "Ship a Python project as one self-installing binary"
version "0.1.0"

INSTALL_BASE = "${HOME}/.cache/perch/stt_bin"
LAUNCHER     = "${HOME}/.local/bin/stt"

command install
    do
        h         = bundle_hash
        installed = exists "${INSTALL_BASE}/${h}/.installed"
        if installed
            print "βœ“ Already installed at ${INSTALL_BASE}/${h}"
        end
        if not installed
            print "β†’ Extracting"
            bundle_extract "${INSTALL_BASE}/${h}"
            cd "${INSTALL_BASE}/${h}"
            python3 -m venv .venv
            .venv/bin/pip install --quiet -r requirements.txt
            write_file "${INSTALL_BASE}/${h}/.installed" "ok\n"
            mkdir "${HOME}/.local/bin"
            # Compose the launcher line-by-line β€” capy strings are
            # single-line; for multi-line files we use append_line.
            write_file  "${LAUNCHER}" "#!/usr/bin/env bash"
            append_line "${LAUNCHER}" 'exec ${INSTALL_BASE}/${h}/.venv/bin/python ${INSTALL_BASE}/${h}/main.py "$@"'
            exec chmod +x ${LAUNCHER}
            print "βœ“ Installed.  Run: stt example.wav"
        end
    end
end

command run
    description "Run the embedded program directly (skips the launcher)"
    proxy_args
    do
        dir = bundle_dir
        shell "python3 ${dir}/main.py ${proxy_args}"
    end
end

command uninstall
    do
        rm "${INSTALL_BASE}"
        rm "${LAUNCHER}"
    end
end

Build + use:

perch --build -f commands.perch --include ./src -o stt_bin
./stt_bin install                # β†’ ~/.local/bin/stt now exists
stt example.wav                  # use it like a native binary
./stt_bin run example.wav        # bypass the launcher (no install needed)
./stt_bin uninstall              # rm the install + the launcher

The ~12 MB stt_bin carries: the perch runtime + your parsed commands.perch + a tarball of ./src/. The recipient needs only python3 (or whatever your install command requires). No pipx, no virtualenv setup, no pip --user permission drama, no internet access at install time.

Same pattern, other ecosystems:

Language / kind What --include ships What install does
Python (the example above) main.py + requirements.txt + data files python3 -m venv + pip install + write launcher
Node / TypeScript package.json + compiled dist/ npm install --omit=dev (or skip if pre-bundled) + launcher
Ruby sources + Gemfile bundle install + launcher
Pre-compiled native binary the binary itself just cp into PATH; no install step needed
Static assets (HTML/CSS/JS site) the whole site/ tree extract to ~/.local/share/<tool>/; --server exposes it as a local web app
Configuration / dotfiles your dotfiles repo extract to $HOME; run stow or symlink-write
A whole monorepo src/ containing Go + Python + JS per-language sub-install commands chained from install

Why this pattern is unusually good:

  1. No package registry, no broken dependencies. What you --include is what the user gets β€” hash-addressed, byte-identical.
  2. Reproducible. Same --build β†’ same hash β†’ same install β†’ byte-identical files on disk. Compare with pip install x==1.2.3, where the resolved dependency set depends on the day of the week.
  3. Multiple versions coexist. Different bundle_hash β†’ different install dir. Switching is just rewriting the launcher.
  4. Offline-friendly. No pip install from PyPI at install time (you can pre-vendor wheels into the bundle and pip install --no-index --find-links).
  5. Cross-platform if os == in the install command β€” one bundle, OS-correct install steps.
  6. Re-use the same --server / MCP frontends. Non-engineers click "install" in a browser; an AI agent calls stt_bin_install over MCP.

Other applications that fall out of the same primitive:

  • Self-updating bundles. Your binary has an update command that downloads a newer version of itself, verifies sha256, atomically replaces $0.
  • Plugin / extension installers. Each plugin ships as a perch binary. The host app shells out to it for install/uninstall.
  • Internal package registry replacement. Skip npm/PyPI for internal tools: serve perch-built binaries at https://internal/tools/. Each is one self-contained installer.
  • Lambda / serverless deployers. The Lambda's code is --include-d. mytool deploy zips it and uploads.
  • Game mods. The mod's files travel inside a perch binary that knows the host game's directory layout per OS.
  • Reproducible dev environment. --include your .tool-versions + a curated set of tarballs. mydev install lays them out at exact versions, no asdf/nix install required.

β†’ Worked demo: demos/05-python-installer.


8. Cross-platform machine setup / new-hire onboarding

A new engineer joins. They need to install ~10 packages, clone two private repos, set five env vars, and run make init. Today that's a README with instructions that drift, or a setup.sh that doesn't work on Windows.

command setup
    description "Bootstrap a fresh dev machine"
    do
        if os == "darwin"
            brew install jq ripgrep watchexec gh
        end
        if os == "linux"
            sudo apt-get install -y jq ripgrep gh
        end
        if os == "windows"
            choco install jq ripgrep watchexec gh -y
        end
        if not exists "${HOME}/.team-config"
            git clone git@github.com:org/team-config ${HOME}/.team-config
        end
        write_file "${HOME}/.zshrc.team" "export TEAM_CONFIG=${HOME}/.team-config\n"
        print "Done. Add 'source ~/.zshrc.team' to your shell rc."
    end
end

Then perch --build -o team-bootstrap and your onboarding doc shrinks to:

curl -fsSL https://internal/team-bootstrap -o bootstrap && chmod +x bootstrap && ./bootstrap setup

Why perch wins: one file covers three OSes with first-class branching. if not exists is the conditional you'd otherwise express with [ -d X ] || .... The result ships as a single binary, so the user doesn't need to install Node, Python, or perch first.

β†’ Worked example: tutorials/03-cross-platform-installer and demos/02-cross-platform-setup.


9. Safe operational surface for non-engineers

Your support team needs to flush a customer's cache, regenerate an invoice, or rotate an API key. Today they Slack you and you do it from your laptop. Or worse, you give them shell access.

perch's web UI mode turns commands.perch into a real internal tool:

command flush_cache
    description "Flush the cache for one customer"
    arg customer_id
        type string
        description "Customer UUID"
    end
    do
        redis-cli DEL cache:${customer_id}
        print "Cache cleared for ${customer_id}."
    end
end

command rotate_key
    description "Rotate the API key for one customer"
    arg customer_id
        type string
        description "Customer UUID"
    end
    do
        new_key = sha256_file "/dev/urandom"
        shell `psql -c "UPDATE customers SET api_key='${new_key}' WHERE id='${customer_id}'"`
        print "New key: ${new_key}"
    end
end
perch --server --port 8080 --host 0.0.0.0

The support team gets a form for each command, with arg validation. Output streams as NDJSON to the browser. They never touch a shell. You expose exactly the operations you want, no more.

Why perch wins: writing this as a Flask/Express app is a multi-day project. perch makes it minutes. The private modifier hides helper commands that shouldn't be on the dashboard.


10. CI pipeline as code (one source for local + remote)

The classic problem: your .github/workflows/ci.yml and your local make test slowly diverge. Six months in, "it passes locally but fails in CI" is the team's most-said phrase.

command ci
    description "What CI runs end-to-end"
    do
        lint
        test
        release
    end
end

command lint
    do
        go vet ./...
        if exists "${HOME}/go/bin/staticcheck"
            ${HOME}/go/bin/staticcheck ./...
        end
    end
end

command test
    do
        go test -race -cover ./...
    end
end

command release
    do
        with_env "GOOS=darwin"
            go build -o bin/darwin/myapp ./cmd/myapp
        end
        with_env "GOOS=linux"
            go build -o bin/linux/myapp ./cmd/myapp
        end
        with_env "GOOS=windows"
            go build -o bin/windows/myapp.exe ./cmd/myapp
        end
    end
end

The whole CI definition shrinks:

# .github/workflows/ci.yml
- run: perch ci

Why perch wins: the matrix lives in commands.perch, not in YAML you also have to maintain. Developers run the exact same perch ci locally. Drift becomes impossible.


11. Data pipeline / ETL orchestration

You have 10 shell scripts that run nightly via cron. They download, decompress, transform, upload. Half of them broke once when curl's flag syntax changed; the other half broke when someone renamed a JSON field.

command nightly_etl
    description "Pull the daily report, transform, upload"
    do
        stamp = now "date"
        download "https://reports.example.com/${stamp}.json.gz" "/tmp/r.json.gz"
        ungzip "/tmp/r.json.gz" "/tmp/r.json"
        body = read_file "/tmp/r.json"
        users = json_get "${body}" "users"
        count = length users
        if count == 0
            fail "report has no users"
        end
        payload = json_stringify users
        http_post "https://warehouse.example.com/ingest" payload
        rm "/tmp/r.json"
        rm "/tmp/r.json.gz"
        print "ETL complete: ${count} users."
    end
end

Why perch wins: download, ungzip, json_get, http_post are first-class ops with consistent error handling. No shell quoting drama. --check flags typos before the cron fires. If you need this to be more reliable, drop in if exists "X" … end guards and fail checks.

Not a full Airflow replacement, but for the "I have 10 shell scripts and 5 of them have bit-rotted" tier, it's a substantial upgrade.


12. AI-agent tooling (curated MCP surface)

Give an AI agent (Claude Desktop, Claude Code, Cursor, Zed) the ability to do things in your environment β€” restart services, query metrics, fetch logs β€” without giving it shell access.

perch-mcp exposes the program as two MCP tools (perch_list, perch_run). Configure it in your MCP client:

{
  "mcpServers": {
    "ops": {
      "command": "perch-mcp",
      "args": ["-f", "/abs/path/to/ops.perch"]
    }
  }
}

The agent now sees only the operations you declared. It can call them with structured args. Output streams back.

Why perch wins: the grammar is the security boundary. An MCP server that wraps bash -c is a footgun; one that wraps a fixed list of typed commands is auditable. Mark helper commands private and they're not even visible to the agent.

This is a sweet spot: curated, safe, structured execution for AI agents. A small but real industry trend.

β†’ Setup: docs/mcp.md.


13. Self-service runbooks

Every on-call engineer has a folder of runbooks. They're markdown files with shell snippets. Half the snippets stop working when the cluster gets upgraded.

Convert each runbook to a command:

command runbook_db_failover
    description "Failover the primary DB to the standby"
    do
        print "Step 1: drain connections"
        kubectl exec -n prod db-primary -- pg_drain
        print "Step 2: promote standby"
        kubectl exec -n prod db-standby -- pg_ctl promote -D /var/lib/postgresql/data
        print "Step 3: update DNS"
        aws route53 change-resource-record-sets --hosted-zone-id Z123 ...
        print "Done. Verify with: perch runbook_db_status"
    end
end

command runbook_db_status
    description "Read replication lag from each replica"
    do
        kubectl exec -n prod db-primary -- psql -c "SELECT * FROM pg_stat_replication"
    end
end

perch --help becomes your runbook index. perch runbook_db_failover is the runbook itself. CI runs perch --check against the file weekly to catch bit-rot before an incident does.

Why perch wins: runbooks become executable. Markdown is a lie that the runbook still works; perch's static validator + the fact that the command is the runbook means there's a single source of truth.


14. Configuration management (Ansible-lite)

For small jobs that don't justify Ansible/Chef/Puppet:

command provision_box
    description "Bring a fresh VM up to spec"
    do
        if os == "linux"
            sudo apt-get update -qq
            sudo apt-get install -y nginx postgresql-client
        end
        write_file "/etc/nginx/sites-available/myapp" "
server {
    listen 80;
    server_name myapp.local;
    location / {
        proxy_pass http://localhost:3000;
    }
}
"
        sudo ln -sf /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
        sudo systemctl reload nginx
    end
end

Run it locally for the first time, then perch --build -o provision and SCP to the target. The target machine doesn't need anything pre-installed.

Why perch wins: Ansible is great but overkill for a 20-line setup. Bash works but needs set -euo pipefail and quoting discipline. perch sits in between: declarative, typed args, op handlers that don't break on whitespace.


15. Multi-target build orchestration for game / native development

Game studios + native-app teams have weird per-platform build dances: Xcode for iOS, Gradle for Android, MSBuild for Windows, plus signing + notarization + uploads to four stores.

command release
    description "Cross-platform release"
    do
        build_ios
        build_android
        build_macos
        build_windows
        upload_all
    end
end

command build_ios
    require_os "darwin"
    do
        xcodebuild -scheme MyGame -configuration Release archive
        xcodebuild -exportArchive -archivePath ... -exportPath ./build/ios
    end
end

# ...

require_os "darwin" enforces that iOS builds can only run on a Mac. The rest of the matrix branches via if os ==.

Why perch wins: the platform branching is structural, not buried in shell case statements. CI failures point at the exact step. --build lets you ship a release-cli binary to designers who need to do trial builds without learning the build system.


16. Personal "me" CLI (dotfiles, side projects)

Personal automation. The kind of thing where you'd otherwise have ~/bin/blog-deploy, ~/bin/backup, ~/bin/cleanup:

command deploy_blog
    do
        cd "${HOME}/projects/blog"
        hugo --minify
        rsync -av public/ user@host:/var/www/blog/
    end
end

command backup
    do
        stamp = now "date"
        tar_create "${HOME}/Documents" "/tmp/docs-${stamp}.tar.gz"
        rclone copy /tmp/docs-${stamp}.tar.gz dropbox:Backups/
        rm "/tmp/docs-${stamp}.tar.gz"
    end
end

command cleanup
    do
        rm "${HOME}/Library/Caches/Google/Chrome"
        rm "${HOME}/Library/Developer/Xcode/DerivedData"
        print "Cleared caches."
    end
end

Build it once (perch --build -o ~/bin/me) and now me deploy_blog is your tool.

Why perch wins: you'd otherwise be looking up tar flag syntax for the 50th time. perch's ops are stable, autocompleted (via LSP), and --check-able.


17. Scaffold / template generator

You have a "create a new microservice" recipe that does ~20 things: clone a template, rename files, set up CI, register with service discovery, …

command new_service
    arg name
        type string
        description "Service name (kebab-case)"
    end
    do
        git clone git@github.com:org/service-template ${name}
        cd "${name}"
        rm ".git"
        git init -q
        # find/replace placeholders
        find . -type f -exec sed -i "" s/__NAME__/${name}/g {} +
        shell "echo '${name}' > .servicename"
        gh repo create org/${name} --private --source .
        print "Created ${name}. cd into it and start hacking."
    end
end

Why perch wins: every team has this recipe. Today it's a shell script with a set -e at the top and prayer. With perch, args are typed, descriptions show in --help, and --check catches typos before you run it on a new repo.


18. Documentation site / blog tooling

The kind of thing that should be Make but ends up being five separate scripts:

command preview
    do
        shell_detached "mkdocs serve"
        sleep 2
        if os == "darwin"
            open http://127.0.0.1:8000
        end
        if os == "linux"
            xdg-open http://127.0.0.1:8000
        end
    end
end

command publish
    do
        mkdocs build --strict
        rsync -av site/ user@host:/var/www/docs/
    end
end

Why perch wins: the cross-platform open / xdg-open branching is automatic. shell_detached for the background mkdocs serve is a real op, not a & you have to remember.


19. Quick API smoke tests / health probes

You want a "is the system OK?" command that pings five services and reports.

command health
    description "Probe core services and report"
    do
        api    = http_get "https://api.example.com/health"
        auth   = http_get "https://auth.example.com/health"
        db_ok  = port_check "db-primary.internal" "5432"
        cache  = port_check "cache.internal" "6379"

        print "api:   ${api}"
        print "auth:  ${auth}"
        print "db:    ${db_ok}"
        print "cache: ${cache}"
    end
end

Pair with a cron, or expose via --server for a real-time dashboard.

Why perch wins: http_get + port_check are first-class. No curl --fail-and-grep gymnastics. Combine with if not api for failure exits.


20. AI-assisted refactor / migration tools

Internal tools that combine human-driven and AI-driven operations:

command migrate_to_v2
    description "Run the v2 migration on this repo"
    do
        go mod edit -droprequire github.com/old/lib
        go mod edit -require github.com/new/lib@latest
        find . -name *.go -exec sed -i "" s/old.Foo/new.Foo/g {} +
        go mod tidy
        go vet ./...
        print "Migration applied. Review the diff and run tests."
    end
end

Expose via perch-mcp and Claude can drive the migration command-by-command, checking output after each step. The grammar restricts what the agent can do; the migration logic is in your code.

Why perch wins: the typed surface is exactly what an LLM needs. Agents handle perch's structured tools far more reliably than bash -c with prose.


21. Embedded scripting for your own Go program

Less obvious but real: you can import perch's capy loader + interpreter as a Go library to give your program a scriptable interface. Think: vim's vimscript, or Emacs's elisp, but for a Go application.

import (
    "github.com/olivierdevelops/perch/infra/capyloader"
    "github.com/olivierdevelops/perch/infra/interpreter"
    "github.com/olivierdevelops/perch/infra/ops"
)

func main() {
    prog, err := capyloader.LoadFromString(userScript)
    if err != nil { /* show error */ }
    i := interpreter.New(ops.AllHandlers(), prog)
    i.Run("user_cmd", nil)
}

Your users can write .perch files that drive your program. You control which op handlers are available β€” register custom ones for your domain.

Why perch wins: if you'd otherwise embed Lua or Starlark, perch gives you a smaller surface, a real LSP for users, and an MCP server for free.


22. Workshops, tutorials, demos

If you teach DevOps / CI / cross-platform tooling, perch is genuinely a useful classroom tool. The DSL is small enough that students learn it in 20 minutes. The op catalog gives them real capability without "first install jq, brew install …". A commands.perch is a small, complete, runnable artifact you can hand them.

Why perch wins: alternatives are either too complex (Ansible) or too low-level (bash) to teach in one sitting.


23. Internal-tools framework for low-engineering teams

Marketing / design / ops / product orgs sometimes want a "button" that triggers something:

  • "Recompute weekly metrics"
  • "Refresh the staging data from prod"
  • "Generate the monthly invoice PDFs"

You write a commands.perch with those commands. Run perch --server --host 0.0.0.0 --port 8080 on a VM. Hand the URL to the team. They click buttons; they fill in args; they see streamed output. No engineer in the loop after setup.

Why perch wins: writing this UI in Retool / internal-tool platforms costs $$. perch costs zero. The buttons are command declarations.


When not to use perch

This part matters. perch isn't always the right tool.

Don't reach for perch when… Use this instead
You need full Turing-complete control flow (deep loops, complex data structures, custom types). A real programming language. perch's let + if EXPR covers ~80% of scripting needs but isn't the right tool for serious logic.
You need a workflow engine with retries, scheduling, observability, backfills. Airflow, Prefect, Dagster, Temporal.
You're doing infrastructure as code at scale (declarative diffs, drift detection). Terraform, Pulumi, OpenTofu.
Multi-host configuration with idempotent diffs. Ansible, Chef, Puppet.
Performance-critical hot paths. perch's interpreter is fine for a thousand ops; it's not a 10K-ops-per-second engine.
You need a real templating / code-generation engine. capy (perch's underlying DSL engine) or a project-specific generator.
Highly dynamic command sets (commands defined at runtime by external data). Build a real Go program; use perch's interpreter as a library.

The honest framing: perch wins for the middle 80% of small tooling tasks. Below it (one-line find . -name '*.tmp' -delete style stuff), bash is fine. Above it (real production pipelines with monitoring + retries), you want a workflow engine. The middle band is where perch is happiest, and that band turns out to be most of the daily-friction tooling work in any organisation.


Combining frontends β€” compound applications

The four frontends (CLI / server / shell / build / MCP) compose. Some patterns:

Pattern: "developer tool that ships to ops." You build a commands.perch you use locally as perch foo. Once it's stable, perch --build -o ops and hand it to the ops team. They use the same commands; you ship updates by rebuilding.

Pattern: "AI-assisted ops dashboard." Same commands.perch runs three ways: ops team uses perch --server in a browser; the on-call engineer uses perch <cmd> from a terminal; an LLM uses perch-mcp to suggest and execute fixes from a chat window. Same commands, three audiences.

Pattern: "self-replicating dev environment." Your team's dev-setup.perch does the new-machine setup. Build it (perch --build -o dev-setup). Commit the binary to a private bucket. Onboarding doc says curl … && ./dev-setup setup. The recipient is now running your perch program β€” and inside it, they dev-setup ship-this to make THEIR project shippable the same way.

Pattern: "audit log of every op." perch --server streams every op as NDJSON to clients. Pipe that stream into your observability stack. You get a free audit log of every operation anyone ran against the operational surface β€” without writing logging into each command.


A few opinionated patterns

Some things that have shaken out as best-practice from real use:

Put everything stable in top-level bindings. Paths, names, build dirs, common env. If you reference it from two commands, hoist it.

Use private aggressively for helpers. Anything that isn't meant as a top-level operation gets private so --help stays clean and the MCP surface stays minimal.

catch for human-friendly errors. If you ship the binary to non-engineers, a catch unknown that lists valid commands is the difference between "looks broken" and "looks polished."

Group commands by verb-first naming. db_backup / db_restore / db_migrate reads better than backup_db / restore_db and groups in --help.

Treat perch --check as part of CI. Catch typos, missing command invocations, unresolved ${name} placeholders before they hit prod.

Build for the slowest target first. A perch --build -o myapp on macOS produces a Mach-O arm64 binary. For wider distribution you need to wait for cross-compile support (roadmap), or run --build on each target's CI runner.


Where to go next

If your use case fits a category above and you've built something interesting, open a PR adding a demos/ folder for it. The catalog grows from real examples.