Skip to main content

Automate bookkeeping with bea

Script your Beancount books with bea: resolve the ledger explicitly, parse the JSON envelope with jq, branch on exit codes, run unattended, and schedule a nightly check.

A script drives bea with four decisions: which ledger it reads, --json for machine-readable output, jq for the value it needs, and the exit code it branches on. This guide walks those four decisions end to end, then schedules them.

You need bea on the machine that runs the job and a ledger it can reach. If you are starting new books, follow the CLI quick start first. Every fact about flags, envelope keys and exit codes is looked up in the Beancount CLI reference, not repeated here.

Pick the ledger explicitly

Name the file. A local command resolves its target from --file, then $BEA_FILE, then ./main.bean in the working directory, and a scheduled job rarely runs where you think it does.

bea --file ~/books/main.bean check
BEA_FILE=~/books/main.bean bea check
cd ~/books && bea check

Global options go before the command, as in bea --file main.bean check. If the resolved file does not exist the command exits 2 and names all three sources, so a typo in a cron entry fails loudly instead of validating the wrong books. Hosted targeting through a --ledger flag does not exist yet; bea never uploads a local file implicitly.

Read the JSON envelope

Add global --json and every supported command answers with the same envelope: bea, target, data, truncated, and limit on bounded lists. Amounts are decimal strings and dates are ISO YYYY-MM-DD, so a value is safe to compare without a float ever entering the pipeline. The envelope's keys are tabulated in the JSON and exit-code reference.

bea --json --file main.bean report income-statement | jq .data.net_profit
bea --json --file main.bean list transaction --limit 2 | jq '.data[0].postings[0].units'
bea --json --file main.bean import statement.csv --csv date=Date,amount=Amount,payee=Payee \
  --account Assets:Checking --apply --duplicates skip | jq '.data | {written, ready, duplicates}'

Those three commands — report, list and import — keep their result shapes, so a jq path written against them stays valid. bea --json check and bea --json query also emit the envelope today, but they are the commands being handed to the native Beancount executables, so a script should key on check's exit status rather than its output shape. Pick your duplicate policy deliberately: --duplicates is still required when an import needs a decision, as the import walkthrough explains.

Stop on the right exit code

Branch on the status, and read the error object before retrying anything that writes. In --json mode a failure writes nothing to stdout and exactly one object to stderr, whose error.category names the class: validation (1), usage (2), auth (3), conflict (4).

#!/usr/bin/env bash
set -euo pipefail
 
out=$(mktemp)
err=$(mktemp)
status=0
 
bea --json --file main.bean report income-statement >"$out" 2>"$err" || status=$?
 
case "$status" in
  0) jq -r '.data.net_profit | to_entries[] | "net profit: \(.value) \(.key)"' "$out" ;;
  4) echo "conflict — inspect the ledger before retrying" >&2
     jq -r '.error.message' "$err" >&2
     exit 4 ;;
  *) jq -r '.error | "\(.category) (exit \(.exit_code)): \(.message)"' "$err" >&2
     exit "$status" ;;
esac

Exit 4 is the one a script must never retry blindly: it means the outcome is a conflict or is unknown, such as an external edit arriving mid-write or an init target that already exists. Inspect the ledger, then retry from a fresh read. Exit 1 covers validation failures and any other runtime error; error.details carries the individual ledger errors, and error.result carries what a partial write actually did. A nonzero exit never guarantees that nothing changed.

Run without a terminal

bea stops prompting on its own. --no-input is implied whenever stdin is not a terminal, whenever --json is set, and whenever CI is truthy — 1, true, yes or on. In that mode a missing confirmation fails with exit 2 instead of waiting forever.

CI=true BEA_NO_UPDATE_NOTIFIER=1 bea --json --file main.bean report balance-sheet
bea --json --file main.bean list transaction --limit 100 --sort oldest

Reads are lenient in a terminal and strict everywhere else. When the ledger has loader errors, query, list and report exit 1 under --json, under a piped stdout, under a truthy CI, or with --strict; pass the command's own --allow-errors to accept the partial answer instead, which also sets ledger_valid: false and fills ledger_errors in the JSON. --strict is the mirror image: it refuses partial answers even in a terminal, which is what you want when a human runs the same script by hand. bea check has no --allow-errors — reporting errors is its whole job — and always exits 1 when it finds any. Set BEA_NO_UPDATE_NOTIFIER=1 to silence the passive update notice; a truthy CI already does.

Schedule a check

Run a validation every night and let the exit code be the alert. Both blocks below are templates — the paths, the schedule and the runner are yours.

# crontab -e — 07:15 daily; cron mails you only when bea exits nonzero
15 7 * * * BEA_NO_UPDATE_NOTIFIER=1 /opt/homebrew/bin/bea --file /home/alice/books/main.bean check
name: ledger
on:
  schedule:
    - cron: "15 7 * * *"
  push:
jobs:
  check:
    runs-on: ubuntu-latest
    env:
      BEA_NO_UPDATE_NOTIFIER: "1"
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v5
      - run: uv tool install beancount-io==0.1.0
      - run: bea --file main.bean check
      - run: bea --json --file main.bean report balance-sheet > balance-sheet.json

Pin the version when the job must be reproducible, and drop the pin when you would rather track releases. CI is already truthy on GitHub Actions, so prompts are off and the update notice is silent before you set anything. There is no formatting step here on purpose. A scheduled job should not rewrite files it did not have to, so reach for bea format --check in a pre-commit hook, which touches nothing and exits 1 when a file needs formatting.

Use a hosted credential in a job

Set BEA_TOKEN, from your CI provider's secret store, and skip the browser sign-in entirely. The token is read from the environment and never written to disk, so nothing lands in the runner's home directory for the next job to find.

export BEA_TOKEN="$YOUR_CI_SECRET"
bea --json cloud status

Exit 0 means the credential resolved and the envelope names the account it belongs to; exit 3 with error.category of auth means it did not, and the message distinguishes an unset credential from a rejected one. bea cloud logout does nothing to a token supplied this way — it neither revokes it nor unsets it, since another job may share it — so revoke a leaked token from the dashboard instead. Local commands need no credential at all; only bea cloud and bea ask reach the hosted service. The full variable list is in the settings reference.

Not everything answers in JSON. bea ask rejects JSON mode outright, bea cloud login needs a human, and a successful bea cloud logout or bea cloud ledger clone returns no JSON success object — read their exit status instead. Help, version and shell-completion output stay textual.

Next steps

Source: https://beancount.io/docs/Solutions/automate-bookkeeping-with-bea