A self-hosted Go service that syncs your SimpleFIN financial data into a local SQLite (or Postgres) database and serves it over a REST API and a built-in MCP server.
- Single static binary, no CGO — pure-Go SQLite (
modernc.org/sqlite) and pure-Go Postgres (jackc/pgx). - SQLite or Postgres — zero-dependency on embedded SQLite by default, or point it at a Postgres server with one config change. Same binary either way.
- Web dashboard at
/— an account overview + a filterable, sortable, paginated transactions table with inline, editable transaction labels (key:value pairs, with typeahead suggestions), built with go-app (Go → WebAssembly, embedded in the binary; no Node/JS build). - One small container (
scratchbase — ~12 MB pulled, ~24 MB on disk for linux/amd64; the embedded WASM dashboard adds ~5 MB) with a bind-mounted SQLite file. - No external dependencies. Optionally store the SimpleFIN access URL in
HashiCorp Vault; otherwise it lives in a local
0600file. - Prometheus metrics at
/metrics, structuredsloglogging, graceful shutdown.
A background scheduler (gocron) polls a SimpleFIN bridge on an interval and
upserts organizations and accounts. New transactions are inserted by their
SimpleFIN id; a transaction that already exists has its bridge-owned fields
refreshed (so a pending charge that later posts, or a corrected amount, flows in)
while any labels you added are always preserved — a sync never clobbers your
labels. Every run is recorded in sync_log. You can also force a sync from the
dashboard's Settings page or POST /api/v1/sync.
SimpleFIN bridge ──poll──▶ kasas ──▶ SQLite ──▶ REST API (/api/v1/...)
└──▶ MCP server (/mcp)
Prebuilt multi-arch (amd64/arm64) images are published to GHCR on every release:
docker pull ghcr.io/paulmeier/kasas:latest-
Get a setup token from your SimpleFIN bridge (a base64 string).
-
Start the service (the Compose file builds locally; swap in the GHCR
image:to use the published one):export KASAS_SIMPLEFIN_SETUP_TOKEN="<your base64 setup token>" docker compose up -d --build
-
The token is claimed on first sync and the resulting access URL is persisted to
./data/secrets.json, so the token is only used once. Check it worked:curl localhost:8080/api/v1/sync # latest sync status curl localhost:8080/api/v1/accounts # synced accounts
Volume permissions: the container runs as UID
65532. The mounted data directory must be writable by that user:mkdir -p data && sudo chown -R 65532:65532 data. On Unraid, point the volume at/mnt/user/appdata/kasasand adjust ownership to match.
Requires Go 1.25+ (only to build; the running service needs nothing else).
cp config.example.toml config.toml # edit as needed
make build
./bin/kasas -config config.toml serveOr run a single sync and exit:
KASAS_SIMPLEFIN_SETUP_TOKEN="..." ./bin/kasas -config config.toml syncConfiguration comes from a TOML file (-config path) and/or environment
variables. Env vars are prefixed KASAS_, with sections joined by underscores
([server].addr → KASAS_SERVER_ADDR) and win over the file. See
config.example.toml for every option.
| Key | Env | Default | Description |
|---|---|---|---|
server.addr |
KASAS_SERVER_ADDR |
:8080 |
HTTP listen address |
database.driver |
KASAS_DATABASE_DRIVER |
sqlite |
Backend: sqlite or postgres |
database.path |
KASAS_DATABASE_PATH |
/data/kasas.db |
SQLite file path (driver=sqlite) |
database.dsn |
KASAS_DATABASE_DSN |
— | Postgres connection string (driver=postgres) |
simplefin.setup_token |
KASAS_SIMPLEFIN_SETUP_TOKEN |
— | One-time base64 setup token |
simplefin.access_url |
KASAS_SIMPLEFIN_ACCESS_URL |
— | Pre-claimed access URL |
sync.interval |
KASAS_SYNC_INTERVAL |
6h |
Poll interval (Go duration) |
sync.lookback_days |
KASAS_SYNC_LOOKBACK_DAYS |
90 |
History window; 0 = all |
vault.enabled |
KASAS_VAULT_ENABLED |
false |
Use Vault for the access URL |
mcp.enabled |
KASAS_MCP_ENABLED |
true |
Mount the MCP server at /mcp |
update.check |
KASAS_UPDATE_CHECK |
true |
Daily check for a newer release (logs + dashboard banner) |
update.allow_apply |
KASAS_UPDATE_ALLOW_APPLY |
true |
Let the dashboard/API trigger an in-place self-update |
kasas runs on SQLite (default) or Postgres, selected at runtime — the same binary supports both. Schema migrations and type-safe queries are generated for each dialect, so switching backends needs no code changes.
SQLite (default) needs nothing: an embedded, file-based database opened in
WAL mode at database.path. Ideal for a single-container deployment.
Postgres — point kasas at a server and it creates its schema on first start:
KASAS_DATABASE_DRIVER=postgres \
KASAS_DATABASE_DSN="postgres://user:pass@host:5432/kasas?sslmode=disable" \
kasas serveWith Docker Compose, an optional Postgres service is included:
# edit docker-compose.yml: set KASAS_DATABASE_DRIVER=postgres + KASAS_DATABASE_DSN
docker compose --profile postgres up -dSwitching backends does not migrate existing data between them; each backend keeps its own database.
Docker — pull the new image and recreate the container:
docker pull ghcr.io/paulmeier/kasas:latest
docker compose up -dBinary — every release publishes static binaries (linux/darwin × amd64/arm64) with SHA-256 checksums to GitHub Releases. The binary can update itself in place:
kasas self-update # download, verify, and replace the running binary
kasas self-update -check # report whether a newer release exists; install nothingself-update fetches the latest release, downloads the asset matching your
OS/arch, verifies it against the published .sha256 (refusing to proceed on a
mismatch or a missing checksum), and atomically replaces the binary. You need
write access to its directory; restart the service afterwards to run the new
version.
While serve runs, kasas also checks once a day for a newer release and logs
a notice — it never self-modifies. Disable the check with KASAS_UPDATE_CHECK=false
(recommended for Docker, where you update by pulling a new image). Builds without
a release version (e.g. dev) skip the check entirely.
From the dashboard — when a newer release is available, the dashboard shows a
banner at the top with an "Update & restart" button. Clicking it calls
POST /api/v1/update, which performs the same download → verify → replace as the
CLI and then re-execs the new binary in place (no external supervisor needed);
the page reloads onto the new version once it's back. The button is backed by the
same update.allow_apply switch:
Security: the dashboard and API are unauthenticated, so with
allow_applyon, anyone who can reach kasas can replace the running binary (with a checksum-verified GitHub release) and restart it. Keep kasas on a trusted network — e.g. Tailscale — or setKASAS_UPDATE_ALLOW_APPLY=falseto keep the informational banner while requiring thekasas self-updateCLI to actually upgrade.
When dashboard.enabled is true (the default), kasas serves a lightweight web UI
at the root path (/). A collapsible left sidebar navigates between four pages:
- Dashboard — a balance card per account, and a transactions table (date, account, payee/description, color-coded amount, pending badge) with an account filter, sortable columns, a selectable page size (10/20/50/100), and pagination.
- Labels — every label (a
key: valuepair) with the number of transactions carrying it, and a delete that strips it from all of them. (Labels are created on the Dashboard.) - Rules — a placeholder for upcoming automatic labeling.
- Settings — connect to SimpleFIN by pasting a setup token or access URL (stored securely and used on the next sync, no restart), force a sync with live status, and review the effective configuration (read-only, secrets redacted).
The sidebar collapses to an icon rail; the choice is remembered across pages.
Browsing is read-only except for labels: each transaction has an editable
Labels cell where you can add or remove key: value labels (type e.g.
category: food, or tag: groceries for a simple label), with typeahead
suggestions drawn from your existing labels (the Labels column itself is not
sortable). It's a
go-app PWA — the UI is written in Go, compiled to
WebAssembly, embedded in the binary (served gzipped, ~3 MB), and reads from the
same-origin REST API. Turn it off with KASAS_DASHBOARD_ENABLED=false (the WASM
is still embedded; the route is just not served).
All responses are JSON. Timestamps are RFC 3339 (UTC); money fields are exact decimal strings as returned by SimpleFIN.
| Method & path | Description |
|---|---|
GET /healthz |
Liveness probe (200 ok) |
GET /readyz |
Readiness probe (pings the database) |
GET /metrics |
Prometheus metrics |
GET /api/v1/organizations |
List organizations |
GET /api/v1/accounts |
List accounts (?org_id= to filter) |
GET /api/v1/accounts/{id} |
Get one account |
GET /api/v1/accounts/{id}/transactions |
Transactions for an account |
GET /api/v1/transactions |
List transactions (?label_key= and optional ?label_value= to drill down) |
GET /api/v1/transactions/{id} |
Get one transaction |
PUT /api/v1/transactions/{id}/labels |
Replace a transaction's labels ({"labels":{"category":"food"}}) |
GET /api/v1/labels |
List labels with per-pair transaction counts ([{"key","value","transaction_count"}]) |
DELETE /api/v1/labels/{key} |
Remove a label key from every transaction (add ?value= to scope to one value) |
GET /api/v1/sync |
Latest sync status |
GET /api/v1/sync/history |
Recent sync runs (?limit=) |
POST /api/v1/sync |
Trigger a sync (runs async, returns 202) |
GET /api/v1/config |
Effective configuration, secrets redacted (powers the Settings page) |
PUT /api/v1/simplefin/credential |
Set the SimpleFIN setup token or access URL ({"token":"..."}) |
GET /api/v1/update |
Update status (when update.check is on) |
POST /api/v1/update |
Install the latest release in place (when update.allow_apply is on) |
List endpoints accept ?limit= (default 100, max 1000), ?offset=, and
?since=/?until= (a YYYY-MM-DD date, RFC 3339, or unix seconds).
Labels are strict key: value pairs (both non-empty strings) stored as a JSON
object per transaction. Keys are canonicalized to lowercase; value matching is
exact (case-sensitive). Drill down with ?label_key= (any value) plus an
optional ?label_value= for an exact match — the filter is pushed down to JSON
SQL in both the SQLite and Postgres backends, so callers can build their own
views without scanning every row.
curl "localhost:8080/api/v1/transactions?since=2024-01-01&limit=50"
curl "localhost:8080/api/v1/transactions?label_key=category&label_value=food"When mcp.enabled is true, an MCP server is mounted at /mcp over the
streamable-HTTP transport. It exposes tools: list_accounts, get_account,
list_transactions (with optional label_key/label_value drill-down),
list_labels, list_organizations, sync_status, and trigger_sync.
For desktop MCP clients that launch a subprocess, run it over stdio instead:
kasas -config config.toml mcp/metrics exposes, among the Go/process defaults:
kasas_sync_total{status}— sync runs by outcomekasas_sync_duration_seconds— sync duration histogramkasas_transactions_inserted_total— new transactions insertedkasas_last_successful_sync_timestamp_seconds— last success (unix time)kasas_accounts— accounts seen in the most recent sync
Managed by embedded goose migrations under
migrations/ (one dialect-specific set per backend in
migrations/sqlite and migrations/postgres), applied automatically on startup
(or via kasas migrate):
organizations id, domain, name, sfin_url
accounts id, org_id, name, currency, balance, balance_date, synced_at
transactions id, account_id, amount, pending, date, description, payee, memo, synced_at, labels
sync_log id, started_at, completed_at, status, error
labels is a JSON object of key: value pairs (see REST API).
Type-safe access code is generated from queries/ — shared queries
plus per-dialect queries/sqlite and queries/postgres for the JSON label
filtering — by sqlc for both dialects: SQLite into
internal/db/ and Postgres into
internal/db/pg/. A small db.Store abstraction lets the rest
of the app stay backend-agnostic.
make help # list targets
make generate # regenerate sqlc code after editing queries/ or migrations/
make test # run tests
make test-race # run tests with the race detector
make cover # coverage report (HTML)
make build # static binary -> bin/kasas
make docker # build the container imageTests use testify. Each suite gets an
isolated, fully migrated SQLite database via internal/testutil, so there are
no mocks for the data layer — queries run against real SQLite. Coverage spans
the config loader, the secret store, the generated queries (ordering, filters,
idempotency, foreign keys), the SimpleFIN client and poller (against httptest
servers), the REST API (httptest round-trips), and the MCP tools (driven
through a real in-process client session).
The Postgres backend has an integration test that runs against a real database
when KASAS_TEST_POSTGRES_DSN is set, and is skipped otherwise (so the default
suite needs no database):
docker run -d --name kasas-pg -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=kasas \
-p 5432:5432 postgres:16-alpine
KASAS_TEST_POSTGRES_DSN="postgres://postgres:postgres@localhost:5432/kasas?sslmode=disable" \
go test ./internal/db/cmd/kasas/ main entrypoint and subcommands
cmd/kasas-wasm/ dashboard WebAssembly client entrypoint (GOOS=js GOARCH=wasm)
internal/api/ chi routes, REST handlers, MCP server
internal/dashboard/ go-app dashboard UI + handler (served at /)
internal/config/ viper configuration
internal/db/ SQLite sqlc output + Store interface + Postgres adapter
internal/db/pg/ Postgres sqlc output
internal/poller/ SimpleFIN client + gocron scheduler
internal/vault/ secret store (Vault KV v2, with local-file fallback)
internal/testutil/ shared test database + fixtures
migrations/sqlite/ embedded goose migrations (SQLite)
migrations/postgres/ embedded goose migrations (Postgres)
queries/ shared sqlc query definitions (both dialects)
See CONTRIBUTING.md for local development: configuring and running the service against SQLite or Postgres, the test suite (including the gated Postgres integration test), regenerating sqlc code, linting, and the Conventional-Commits / release-please flow. CI (gofmt, lint, race tests against SQLite and Postgres, and a build stage) must pass on every PR.
