Architecture
Data sources
Section titled “Data sources”| Source | Used for | Needed? |
|---|---|---|
| SimpleFIN Bridge | Balances, transactions and investment positions from your bank and brokerages | SimpleFIN or Plaid |
| Plaid | Card statements and due dates (Liabilities), balances and transactions for accounts you set to Plaid (Transactions), investment holdings and trades (Investments) | SimpleFIN or Plaid; card statements can be entered by hand instead |
| OpenRouter | AI category suggestions (any model; defaults to OpenRouter’s free router) and card lookups (defaults to Claude Haiku) | Optional |
| Ollama (your own server) | The same AI requests, in place of OpenRouter, when it is chosen as the provider in Settings (no web search for card lookups) | Optional |
| Yahoo Finance chart data | Daily and live prices, splits and fund names | Automatic |
| Finnhub | Real-time stock and ETF trades for the live prices (WebSocket; the free plan covers 50 tickers, for personal use) | Optional |
| Realie | Automated home value estimates | Optional |
| Logo.dev | Merchant and bank logos Plaid doesn’t have | Optional |
SimpleFIN, Plaid, the browser extension and the optional services are all configured under Settings → Connections.
How it works
Section titled “How it works”- Forecast: start from today’s balance plus what’s pending, add each recurring item on its dates, spend each budget a little every banking day, and subtract each card’s payment on its due date (the statement and due date from the issuer through Plaid, or the latest one you entered:
runway/domain/statements.pyhas the rules; the whole statement, its minimum or a fixed amount, with the rest and its interest carried into the next one). Nothing is estimated from past spending. Overview has what the user sees. - Investment history: rebuilt from activity where Plaid provides it; otherwise from the position snapshots Runway saves on every sync. Changes in positions between snapshots count as money added or withdrawn, not as gains, and returns are time-weighted.
- Churning: spending toward a card’s bonus is counted the way Reports counts spending (split transactions by their parts, transfers and card payments left out, refunds lowering it). A bank bonus’s direct deposits are deposits categorized as income, or that look like payroll. The banks’ bonus rules live in
runway/domain/churning.pyas data, and every estimate says it is one. Points values are community-consensus estimates with an as-of date (VALUES_AS_OF), not fetched from anywhere: no bank or aggregator (Plaid included) reports what a point is worth, so they’re data you can override per program. Points estimated from spending use a card’s normal rates, since a transaction doesn’t say whether it was booked through the issuer’s portal.
Python with a handful of well-known libraries: SQLAlchemy and Alembic for SQLite/Postgres and migrations (psycopg 3 for Postgres), PyJWT for sign-in tokens, pywebpush for notifications, python-dateutil for recurring schedules and holidays for bank holidays. The web server is the standard library’s. The web app is Svelte 5 and TypeScript (Vite, Tailwind CSS, shadcn-svelte-style components on Bits UI, Lucide icons) with hand-drawn SVG charts and the Geist typeface, built into runway/static/app/ and served at /.
Security and privacy
Section titled “Security and privacy”To report a vulnerability, or for running Runway safely on the internet, see SECURITY.md. In brief:
- Your data stays with you. Everything lives in your database. Outbound calls go only to the services above, and the AI only sees the date, amount, merchant text and account type of transactions you ask it about. Runway sends no error reports or analytics anywhere; errors go to its own log, without secrets or the values of the rows they name.
- Sign-in on the network is mandatory. Runway refuses to listen beyond
localhostwithout OIDC, unless you explicitly say a proxy handles it. - OIDC done carefully: authorization code flow with PKCE. ID tokens are verified with PyJWT against your provider’s published keys (RSA, RSA-PSS, EC or EdDSA;
noneand unexpected algorithms are refused), with issuer, audience, authorized party, expiry, issued-at and nonce checks. Sessions are random tokens stored hashed, inHttpOnly,SameSite=Laxcookies (Secureover HTTPS). - App lock (optional, per device): WebAuthn with the device’s platform authenticator, user verification required. The passkey’s public key is kept with that sign-in’s session and ends with it; each unlock is a single-use challenge checked on the server (origin, relying party, user verified, signature, counter:
runway/applock.py). While locked, the server answers that session’s API calls with 423. For the encrypted on-device cache, the server keeps a random 32-byte key share per device on its lock row, encrypted at rest, and hands it only to that session, unlocked, once after each passkey check (POST /api/lock/key-share). The web app (lib/deviceCache.ts) derives the cache key with HKDF from the passkey’s PRF output and that share, as a non-extractable AES-GCM key held in memory while unlocked; IndexedDB holds only sealed records, bound to their name and schema, and the share sealed under the PRF output for at most 72 hours. Only the lists the in-memory cache already keeps go in, never balances or anything forecast-derived, and backups never carry the share. - Host checking and HTTPS: Runway only answers to addresses you’ve configured or that are clearly local, and with sign-in on it won’t start with an
http://RUNWAY_PUBLIC_URLunless it’s a home-network address. - Secrets encrypted at rest: bank access (SimpleFIN and Plaid access tokens), API keys, the push signing key and devices’ cache key shares are stored encrypted (Fernet, with
RUNWAY_SECRET_KEYor a generated key file), so a copy of the database alone doesn’t give them away, and backup files hold them encrypted the same way (restoring elsewhere needs the same key). - Browser protections: a strict Content-Security-Policy (only Runway’s own scripts, with a per-page nonce, plus Plaid Link), no framing,
Referrer-Policy: no-referrer, HSTS over HTTPS. State-changing requests need Runway’s own header and are refused from other sites; signing out is a POST. Replies with your data are never kept in the browser’s cache (no-store): the web app keeps the last one of each in memory, checked with the server each time (ETag), and forgets them when you sign out. - The browser extension never sees your store passwords: it reads orders with the sign-in in your browser and sends them only to your Runway, with a key you make under Settings → Connections (only its hash is kept, in
runway/domain/retail/token.py; it opens nothing but the extension’s own calls, which can only add orders). Item names and prices go to the AI only if you’ve turned it on. - AI assistants (MCP) connect with OAuth, Runway being its own authorization server: each is approved by a signed-in person on a consent page (form token plus a matching cookie, same-site only), with PKCE (S256 only), exact redirect URIs (https, or loopback http), and tokens bound to the
/mcpaddress. Tokens and codes are stored hashed; refresh tokens rotate and a replayed one revokes the connection. Read is the default; churning changes need both the scope and the Settings switch. Runway makes no outbound requests for OAuth. - Hardened server: request size limits, a timeout for slow clients, a cap on requests handled at once, and errors that never show internals (they log a reference instead). One access-log line per request, without query strings. The container runs as an unprivileged user without extra capabilities, and secrets (
.env,data/, backups) are excluded from Git.