Cacheboard #
Cacheboard is a self-hosted, single-household finance dashboard built with Gleam. The API runs on Erlang/OTP with Mist, Wisp, and SQLite. The browser app is a Lustre single-page application compiled to JavaScript. Money uses integer cents on the wire and the default listener is local-only.
The repository contains three Gleam projects:
shared/holds wire records, JSON encoders, finance semantics, and retirement projection math. It compiles to Erlang and JavaScript.server/owns SQLite through one serializing actor, runs migrations, exposes the HTTP API, serves the production browser bundle, and provides CLI commands.web/is the Lustre application. Chart.js is pinned and bundled locally.
The Agent tab is a persistent conversation workspace. Existing read-only finance tools can place charts, account and budget tables, and assumption summaries directly between the agent's explanation messages.
Product direction and the evidence behind it are documented in
docs/product-research.md. Architecture and local
model research are documented in
docs/research-context.md.
Run with Docker #
Docker is the portable deployment path. It builds a fixed browser bundle and Erlang shipment, runs as a non-root user, keeps all mutable data in one volume, and exposes a container health check.
cp .env.example .env
docker compose up --build -d
Open http://127.0.0.1:3000. A new production database contains the schema and
the built-in category vocabulary only. It contains no sample accounts,
transactions, balances, holdings, budgets, rules, chats, or household settings.
Complete household setup in Settings, then import real data.
Settings also accepts an optional home estimate with its as-of date, source, and link. The value is stored locally and included in net worth; Cacheboard does not scrape Zillow.
The Compose port binds to loopback by default. On a trusted local network, set
CACHEBOARD_BIND=0.0.0.0 in .env. The named cacheboard-data volume survives
container replacement. SQLite requires one running Cacheboard instance and a
local persistent volume; do not place its WAL-mode database on NFS or another
network filesystem.
The container reaches a model server on the host through
host.docker.internal. The model server must listen on an interface reachable
from Docker, or LM_BASE_URL must name another reachable machine.
Toolchain #
Tool versions are pinned in mise.toml and installed with
mise:
mise install
That is the whole setup step. It installs Erlang/OTP 28.4, Gleam 1.18.1, and Bun 1.4.0 for this checkout, independent of anything installed globally.
Every command below runs through mise run <task>, which resolves those
versions. Nothing in this repository edits PATH. That matters because
launchd, cron, and non-interactive SSH do not activate mise, so anything they
invoke has to call mise explicitly rather than assume an activated shell —
see Production.
List the tasks:
mise tasks
Seed and run locally #
Create the synthetic development database:
mise run seed
The seed contains six accounts, 468 transactions, 216 monthly balance
snapshots, budgets, holdings, categorization rules, and retirement settings.
It defaults to the frozen date 2026-07-30. Set CACHEBOARD_TODAY=YYYY-MM-DD
to generate the same shape around another date. It writes to server/data/
and refuses to run unless CACHEBOARD_ENV=development, which the task sets.
Build both applications and run the combined server:
mise run build
mise run serve
Open http://127.0.0.1:3000. To use a different port, set PORT:
PORT=3100 mise run serve
Run a port other than 3000 when the production service is up on this machine, so the two do not fight over the listener.
Production #
This is the real deployment, not an example. Cacheboard runs on the Mac Studio as a launchd user agent, fronted by Tailscale.
| URL | https://averys-mac-studio.tailcb772b.ts.net (tailnet only, not public) |
| Reverse proxy | tailscale serve, / → http://127.0.0.1:3000 |
| Service | launchd user agent com.cacheboard |
| Job definition | ~/Library/LaunchAgents/com.cacheboard.plist |
| Checkout it runs from | /Users/avery/Development/cache-board |
| Data directory | ~/Library/Application Support/Cacheboard, mode 0700 |
| Logs | ~/Library/Logs/cacheboard.out.log, ~/Library/Logs/cacheboard.err.log |
The service binds 127.0.0.1:3000 only. Tailscale terminates TLS and is the
sole path in, so the listener is never exposed to the local network.
Deploying a change #
Build first, then restart the service. The agent runs the built shipment, so nothing you have not built will be picked up:
cd /Users/avery/Development/cache-board
git pull
mise run build
launchctl kickstart -k gui/$(id -u)/com.cacheboard
kickstart -k kills the running instance and starts a fresh one. Confirm it
came back:
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3000/healthz
curl -sS -o /dev/null -w '%{http_code}\n' https://averys-mac-studio.tailcb772b.ts.net/healthz
Both should print 200. Check the exit status of the last run at any time:
launchctl list | grep com.cacheboard
The middle column is the last exit status. 0 is healthy; anything else means
the job is failing and KeepAlive is retrying every 10 seconds.
The launchd job calls mise explicitly #
launchd starts jobs with a minimal environment and does not activate mise, so
erl and gleam are not on its PATH. The job therefore invokes mise by
absolute path and lets it resolve the toolchain from mise.toml:
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/mise</string>
<string>exec</string>
<string>--</string>
<string>./build/erlang-shipment/entrypoint.sh</string>
<string>run</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/avery/Development/cache-board/server</string>
Do not "fix" a missing tool by adding its directory to the job's PATH. That
hardcodes a version — ~/.local/share/mise/installs/erlang/28.4/bin stops
existing the moment Erlang is upgraded — and it silently diverges from
mise.toml. Calling mise keeps one source of truth.
Troubleshooting a 502 #
A 502 from https://averys-mac-studio.tailcb772b.ts.net means tailscale serve
is up but nothing is listening on 127.0.0.1:3000. The proxy is almost never
the problem. Work from the backend out:
lsof -nP -iTCP:3000 -sTCP:LISTEN # empty means the service is down
launchctl list | grep com.cacheboard # middle column is the last exit status
tail -20 ~/Library/Logs/cacheboard.err.log
tailscale serve status # confirm / → http://127.0.0.1:3000
Exit status 127 means the job could not find the program it was told to run —
historically, erl: not found because the job set its own PATH instead of
calling mise. Exit status 1 with a bind error means something else already
holds port 3000, usually a development server started by hand.
Running it in the foreground instead #
./deploy.sh [data-directory] builds and runs the server attached to your
terminal, for when you want to watch it start. It uses the same data directory
as the service, so stop the service first or the port will already be taken:
launchctl stop com.cacheboard
./deploy.sh
On macOS the default data directory is ~/Library/Application Support/Cacheboard.
On other systems it follows XDG_DATA_HOME, falling back to
~/.local/share/cacheboard. An explicit argument or CACHEBOARD_DATA_DIR
overrides it. The directory is created if needed and locked to mode 0700.
Frontend development #
Keep the API running on port 3000, then start Lustre's development server in another terminal:
mise exec -- sh -c 'cd web && gleam run -m lustre/dev start cacheboard_web'
Open http://localhost:5173. The development server proxies /api to
http://127.0.0.1:3000, so it reads whatever database that server is using —
the production one, if the service is running. Point it at the synthetic seed
instead by stopping the service and running mise run seed followed by
mise run serve.
Frontend structure #
The browser app keeps its root model-view-update wiring in web/src/cacheboard/app/.
Each route has a module under web/src/cacheboard/pages/, with larger views
split into feature-local submodules. Shared visual elements live in
web/src/cacheboard/components/ and web/src/cacheboard/ui/; API decoders and
encoders live in web/src/cacheboard/api/.
The existing design system is in web/assets/styles.css. Add small, composable
layout or spacing adjustments with Tailwind utilities in Gleam class lists.
web/src/cacheboard_web.css is the Tailwind entry point, and Lustre builds it
alongside the JavaScript bundle.
Build and test #
mise run test # shared, server, and web unit tests
mise run test-e2e # Playwright browser journeys, builds the bundle first
The browser journeys need Chromium once:
mise exec -- bunx playwright install chromium
The shared package is tested on both targets:
mise exec -- sh -c 'cd shared && gleam test && gleam test --target javascript --runtime node'
The focused tests cover the exact mulberry32 sequence on Erlang and JavaScript, frozen retirement projection output, the bounded agent loop, the frozen tool catalog and model-facing tool-result format, transaction filters and mutations, CSV deduplication and atomic imports, recorded SimpleFIN payloads, GFM configuration, and markdown sanitization.
The browser tests start a separate local server and empty temporary database for each test. They find controls by their visible roles, names, labels, and text.
SimpleFIN #
Claim and verify a SimpleFIN setup token:
cd server
SIMPLEFIN_SETUP_TOKEN='…' mise exec -- gleam run -m cacheboard/cli/simplefin_claim
mise exec -- gleam run -m cacheboard/cli/simplefin_verify
On the Mac Studio these read and write
~/Library/Application Support/Cacheboard, the same directory the service
uses, because CACHEBOARD_DATA_DIR is set there. Elsewhere, set it explicitly.
The access URL is stored as simplefin-access.json in the data directory with
mode 0600. In deployment mode, each page load checks the last successful
SimpleFIN update. Saved dashboard data appears at once, and a new update runs in
the background when the last one is at least 12 hours old. A completed update
reloads the dashboard data.
Synchronization is deployment-only. It can also be run by hand:
CACHEBOARD_ENV=deployment mise exec -- gleam run -m cacheboard/cli/simplefin_sync
The manual command asks how to classify new remote accounts. Automatic updates classify clear account names, including spending, checking, savings, credit, investment, and loan accounts. An unclear new account stops the automatic update with an error so it can be classified with the manual command first.
Synchronization accepts USD accounts, updates balance snapshots, deduplicates remote transactions, replaces matching pending transactions, applies categorization rules, and detects cross-account transfers.
Configuration #
| Variable | Default | Purpose |
|---|---|---|
CACHEBOARD_DATA_DIR |
data/ relative to the working directory |
Directory holding the database and SimpleFIN credentials |
CACHEBOARD_DB |
$CACHEBOARD_DATA_DIR/cacheboard.sqlite |
SQLite database path (overrides the data directory) |
CACHEBOARD_STATIC_DIR |
../web/dist when run from server/ |
Built browser bundle directory |
CACHEBOARD_HOST |
127.0.0.1 |
Listener address; loopback always listens too (tailscale serve) |
PORT |
3000 |
HTTP port |
CACHEBOARD_TODAY |
2026-07-30 |
Synthetic seed date |
CACHEBOARD_ENV |
unset | Must be deployment for SimpleFIN synchronization |
SIMPLEFIN_SETUP_TOKEN |
unset | One-time SimpleFIN claim token |
LM_BASE_URL |
http://localhost:1234/v1 |
OpenAI-compatible local model endpoint |
LM_MODEL |
ternary-bonsai-27b-mlx |
Local model |
LM_TEMPERATURE |
unset (provider default) | Sampling temperature for the agent loop; do not set to 0 |
LM_TURN_TIMEOUT_MS |
300000 |
Maximum total assistant-turn time |
There is no authentication. Keep the default loopback listener unless access is restricted by the deployment environment.
Repository hygiene #
Real financial data and credentials live outside the repository. .gitignore
and .dockerignore exclude environment files, databases, SQLite WAL files,
SimpleFIN credentials, dependency caches, and build output. .env.example
contains names and safe defaults only. The synthetic seed is development-only
and exits unless CACHEBOARD_ENV=development is set explicitly.