This repository has no description
Gleam 87%
Erlang 4%
CSS 4%
JavaScript 3%
TypeScript 2%
Shell <1%
Dockerfile <1%

README.md

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.