Files
aurum/DOCS.md
ZProger 7e4508d1de feat: add a Crypto tab with live CoinGecko prices synced into Net Worth
Track individual coins (identity + quantity) in a dedicated tab, backed by
CoinGecko's Demo API (AURUM_COINGECKO_API_KEY). A holding is just another
Asset under the hood (asset_class=crypto) with its value written as a
regular AssetValuation — the existing Net Worth aggregation needs zero
changes to pick it up.

Prices never refresh in the background (no scheduler in this stack, same
reasoning as recurring transactions): a "Refresh prices" button forces a
sync, and GET /crypto/holdings lazily re-syncs at most once every 24h.
CoinGecko outages leave existing values untouched (error_key: "unreachable")
instead of failing the request. Backup/restore now carries CryptoHolding
rows too, so a restore doesn't silently strip a holding's coin identity.

Backend: 75/75 tests passing (9 new), including a real identity-map
staleness bug the tests caught in the raw-Core-upsert + ORM re-read pattern,
fixed via populate_existing=True.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01855PiW5Wd8n4aRW4n9kByY
2026-08-30 03:58:04 +03:00

28 KiB
Raw Blame History

Aurum API Documentation

Aurum exposes the same REST API its own frontend uses. Every action available in the UI — adding a transaction, creating an account, tagging an expense, importing a CSV statement, tracking an asset, setting a budget — can be done directly over HTTP. This makes it possible to script Aurum, feed it from another program (a bank-sync job, a bot, a shortcut on your phone), or pull your data into your own tools.

There is no SDK — it's a plain JSON REST API, callable with curl, any HTTP client library, or tools like Postman/Insomnia.

Interactive, always-up-to-date docs are also built into the backend itself: once your instance is running, open http://<host>:<port>/api/docs (Swagger UI) or http://<host>:<port>/api/redoc (ReDoc) for a live, "try it out" version of everything below.

Table of Contents

Base URL & Authentication

Aurum ships as three containers (Postgres, FastAPI backend, nginx-served frontend). The frontend container reverse-proxies /api/* straight through to the backend, so the API and the web UI share the same host and port — whatever you set AURUM_WEB_PORT to in .env (default 3000):

http://<host>:<port>/api

For example, on a local install: http://localhost:3000/api. All endpoints below are relative to this base URL — e.g. GET /transactions means GET http://localhost:3000/api/transactions.

Auth

Aurum has no built-in login system or API keys — it's designed for one person to self-host one private instance. Access control is whatever you put in front of it:

  • Nothing set: if AURUM_BASIC_AUTH_USER / AURUM_BASIC_AUTH_PASSWORD are empty in .env (the default), the API is completely open to anyone who can reach the host — no credentials needed. Fine for localhost-only or a private network; not fine on the public internet.
  • HTTP Basic Auth: set both AURUM_BASIC_AUTH_USER and AURUM_BASIC_AUTH_PASSWORD in .env and restart (docker compose up -d). Every request — UI and API alike — then requires an Authorization: Basic <base64(user:password)> header, or the equivalent -u user:password flag in curl.
# No auth configured
curl http://localhost:3000/api/accounts

# Basic Auth configured
curl -u myuser:mypassword http://localhost:3000/api/accounts

GET /api/health is always open (no auth), even with Basic Auth configured — it exists for Docker healthchecks and uptime monitors.

There's no per-endpoint permission model beyond this: whoever can authenticate can read, create, update, and delete everything.

Conventions

  • Format: all request and response bodies are JSON (Content-Type: application/json).
  • IDs: integer, auto-incrementing, assigned by the server.
  • Money fields (amount, monthly_limit, value, ...): decimal numbers as JSON numbers or strings, up to 14 digits with 2 decimal places. Aurum is single-currency per account/asset — there is no built-in FX conversion.
  • Dates: YYYY-MM-DD (ISO 8601 date, no time component). Timestamps (e.g. backup exported_at) are full ISO 8601 datetimes.
  • Colors: 6-digit hex strings, e.g. "#4f46e5".
  • Partial updates: every PATCH endpoint only touches the fields you actually send — omit a field and it's left unchanged. This matters for fields whose "clear this" value is null: sending {"parent_id": null} explicitly clears a category's parent, while omitting parent_id entirely leaves it as-is.
  • Enums are plain lowercase strings — see each resource's table below for the valid values.

Errors

Standard HTTP status codes:

Code Meaning
200 / 201 Success (201 on POST that creates a resource)
204 Success, no response body (deletes)
400 Invalid request — a business rule was violated (e.g. wrong category kind, duplicate budget)
401 Missing/invalid Basic Auth credentials (only when Basic Auth is configured)
404 Resource not found
422 Request body failed schema validation (wrong type, missing required field, out-of-range value)

Error bodies follow FastAPI's default shape:

{ "detail": "Category 'Salary' is a income category and cannot be used for a expense transaction" }

422 validation errors carry a detail array with one entry per invalid field instead of a single string.

Health Check

GET /api/health

No auth required. Returns {"status": "ok"}. Use this to check the backend is up before hitting anything else.

Accounts

An account is a place money lives — a bank account, a card, a cash wallet. Every transaction belongs to exactly one account (transfers touch two). Balances are derived from transactions, not stored — there's no "set balance" endpoint.

AccountType: checking · savings · credit_card · cash · investment · other

Method Path Description
GET /accounts List accounts, with live balance. ?include_archived=true to include archived ones (excluded by default).
POST /accounts Create an account.
PATCH /accounts/{id} Update an account (partial). Set is_archived: true to archive instead of deleting.
DELETE /accounts/{id} Delete an account and every transaction on it — irreversible.

Create/update body:

{
  "name": "Checking",
  "type": "checking",
  "currency": "USD",
  "color": "#4f46e5"
}
  • name: required, 1–100 chars.
  • type: optional, defaults to checking.
  • currency: optional 3-letter code, defaults to USD. Purely a display label — not validated against ISO 4217 and not used for conversion.
  • color: optional hex color.
  • is_archived (update only): archive/unarchive without deleting. An archived account is hidden from the default account list but its transactions and balance remain intact.

Response (AccountWithBalance):

{
  "id": 1,
  "name": "Checking",
  "type": "checking",
  "currency": "USD",
  "color": "#4f46e5",
  "is_archived": false,
  "balance": "1523.40"
}

Categories

Categories classify transactions as income or expense, with one optional level of subcategories (a subcategory's parent_id points at a top-level category of the same kind — no deeper nesting). A handful of default categories are seeded on first run and can't be deleted (is_default: true), though they can be renamed, recolored, and reordered.

CategoryKind: income · expense

Method Path Description
GET /categories List categories, sorted by sort_order. ?kind=income or ?kind=expense to filter.
POST /categories Create a category (or subcategory, via parent_id).
PATCH /categories/{id} Update a category (partial).
DELETE /categories/{id} Delete a category. Fails with 400 if it's a default category.

Create body:

{
  "name": "Groceries",
  "kind": "expense",
  "icon": "shopping-cart",
  "color": "#22c55e",
  "sort_order": 0,
  "parent_id": null
}
  • name: required, 1–100 chars.
  • kind: required, income or expense. Fixed at creation — there's no way to flip a category's kind afterward (create a new one instead).
  • icon: optional, free-form string (the frontend maps these to an icon set).
  • color: required hex color.
  • sort_order: optional int controlling display order, defaults to 0.
  • parent_id: optional. Rules enforced server-side: the parent must itself be top-level (no grandchildren), must share the child's kind, and a category can't be its own parent.

Update accepts the same fields, all optional. Note: to turn a subcategory back into a top-level category, send "parent_id": null explicitly — omitting the field leaves the existing parent as-is. A category that already has subcategories of its own can't be turned into a subcategory.

Response (CategoryRead): same fields as create, plus id and is_default.

Tags

Free-form labels a transaction can carry any number of. Case-insensitive dedup on creation: posting "Georgia" when "georgia" already exists returns the existing tag instead of making a duplicate.

Method Path Description
GET /tags List all tags, alphabetically.
POST /tags Create a tag (or return the existing one, case-insensitive match).
DELETE /tags/{id} Delete a tag — removes it from every transaction that had it.

Create body: {"name": "Vacation"} (1–50 chars).

Response (TagRead): {"id": 1, "name": "Vacation"}.

Transactions

The core resource. A transaction is income, expense, or transfer.

TransactionType: income · expense · transfer

Method Path Description
GET /transactions Paginated, filterable list. See query params below.
GET /transactions/years Every year with data (plus the current year), for a year picker. Returns [2024, 2025, 2026].
POST /transactions Create one transaction.
POST /transactions/bulk Create up to 5000 transactions in one all-or-nothing batch (CSV import).
PATCH /transactions/{id} Update a transaction (partial).
DELETE /transactions/{id} Delete a transaction.

GET /transactions query params

Param Type Notes
year int 2000–2100
month int 1–12
start_date / end_date date Inclusive range, combinable with year/month
account_id int
category_id int
tag_id int
type income|expense|transfer
search string Substring match (case-insensitive) against description, merchant, and notes
sort date_desc|amount_desc|amount_asc Default date_desc
page int Default 1
page_size int Default 20, max 200

Response (TransactionPage):

{
  "items": [ /* TransactionRead[] */ ],
  "total": 143,
  "page": 1,
  "page_size": 20
}

Create body (TransactionCreate)

{
  "account_id": 1,
  "category_id": 4,
  "transfer_account_id": null,
  "type": "expense",
  "amount": 42.50,
  "description": "Groceries",
  "merchant": "Whole Foods",
  "notes": null,
  "date": "2026-08-29",
  "tag_ids": [3, 7]
}

Field rules, enforced server-side:

  • account_id: required, the account the money moves through.
  • type: required. Drives which other fields are valid:
    • income / expense: category_id optional but must point at a category of the matching kind if given (an income transaction can't use an expense category). transfer_account_id must be omitted.
    • transfer: transfer_account_id is required and must differ from account_id. category_id must be omitted — transfers aren't categorized.
  • amount: required, > 0 (transfers/expenses aren't signed negative — direction comes from type), up to 14 digits, 2 decimal places.
  • description: required, 1–255 chars. Auto-capitalized server-side (first letter uppercased).
  • merchant: optional, up to 150 chars.
  • notes: optional, free text.
  • date: required.
  • tag_ids: optional list of existing tag IDs. Unknown IDs return 400.

Update (TransactionUpdate) accepts the same fields, all optional, plus the same type/category/transfer consistency rules applied to the effective (merged) values. tag_ids: omit to leave tags untouched; send (even []) to replace the full tag set.

Response (TransactionRead): the input fields plus id, and the nested account (AccountRead), category (CategoryRead or null), tags (TagRead[]).

Bulk create (POST /transactions/bulk)

Used by the CSV import wizard, but callable directly for any bulk load (e.g. syncing from a bank export tool). All rows are validated before any is inserted — one bad row fails the whole request with no partial import.

{ "items": [ /* 1–5000 TransactionCreate objects */ ] }

Response: {"created": 250}.

Recurring Transactions

Templates for bills/income that repeat on a schedule. Nothing posts automatically in the background — a transaction is only created when you call the /post endpoint (or click "Post" in the UI). This is deliberate: a missed week never silently back-fills a pile of transactions.

RecurringFrequency: weekly · monthly · yearly

Method Path Description
GET /recurring List all recurring templates, with computed due-date info.
POST /recurring Create a template.
PATCH /recurring/{id} Update a template (partial). Set is_active: false to pause.
DELETE /recurring/{id} Delete a template (does not touch already-posted transactions).
POST /recurring/{id}/post Create a real transaction from the template, dated today, and advance the schedule.

Create body: same shape as TransactionCreate minus date/tag_ids, plus:

{
  "account_id": 1,
  "category_id": 4,
  "type": "expense",
  "amount": 15.99,
  "description": "Netflix",
  "frequency": "monthly",
  "anchor_date": "2026-01-05"
}

anchor_date is the first due date; each /post call advances last_posted_date and recomputes next_due_date (monthly clamps to the shortest month, e.g. day 31 → day 28/29/30; yearly Feb 29 falls back to Feb 28 in non-leap years).

Response (RecurringTransactionRead) adds computed fields: next_due_date, is_due (boolean), days_until_due (negative if overdue), plus denormalized account_name/category_name/etc. for display without extra lookups.

Budgets

One monthly spending limit per expense category (income categories can't be budgeted; each category can have at most one budget).

Method Path Description
GET /budgets List all budgets.
GET /budgets/status Budgets vs. actual spend for a month. ?year=&month= (default: current month).
POST /budgets Create a budget. 400 if the category already has one, or isn't an expense category.
PATCH /budgets/{id} Update monthly_limit.
DELETE /budgets/{id} Delete a budget.

Create body: {"category_id": 4, "monthly_limit": 500}.

GET /budgets/status response:

{
  "year": 2026,
  "month": 8,
  "items": [
    {
      "budget_id": 1,
      "category_id": 4,
      "category_name": "Groceries",
      "category_color": "#22c55e",
      "category_icon": "shopping-cart",
      "monthly_limit": "500.00",
      "spent": "612.30",
      "remaining": "-112.30",
      "percent": 122.46,
      "is_over_budget": true
    }
  ]
}

percent can exceed 100 on purpose — clamp it client-side if you're rendering a progress bar.

Goals

Savings goals with a running contribution log. current_amount is always the sum of every logged contribution — there's no separate "set balance" call.

Method Path Description
GET /goals List all goals with computed progress.
POST /goals Create a goal.
PATCH /goals/{id} Update name/target_amount/target_date (partial).
DELETE /goals/{id} Delete a goal and its contribution log.
POST /goals/{id}/contributions Log a contribution (or a withdrawal).

Create body: {"name": "Emergency fund", "target_amount": 10000, "target_date": "2027-01-01"} (target_date optional).

Add contribution: {"amount": 250, "date": "2026-08-29", "note": "Bonus"}. amount may be negative (a withdrawal against the goal) but not zero.

Response (GoalRead):

{
  "id": 1,
  "name": "Emergency fund",
  "target_amount": "10000.00",
  "target_date": "2027-01-01",
  "current_amount": "3250.00",
  "remaining": "6750.00",
  "percent": 32.5,
  "is_reached": false
}

Assets & Net Worth

Assets are manually tracked, non-cash net-worth components — investments, crypto, real estate, vehicles, precious metals, etc. Cash is not an asset; it's derived automatically from account balances and shows up in the net worth summary alongside assets, not as an Asset row.

Each asset has a value history (AssetValuation rows, one per date) rather than a single number — creating an asset seeds its first valuation, and you add more over time to track appreciation/depreciation.

AssetClass: investments · crypto · real_estate · vehicles · precious_metals · other CapitalRole (how it behaves month to month — user-tagged, not inferred): income (e.g. a rented-out apartment) · neutral (e.g. a laptop used for work) · drain (e.g. a depreciating personal vehicle) RiskLevel (risk of loss — user-tagged): low · medium · high

Method Path Description
GET /assets List assets, each with its current (latest) value.
POST /assets Create an asset, seeding its first valuation.
PATCH /assets/{id} Update asset metadata (not its value — use the valuations endpoint).
GET /assets/{id}/valuations Full value history, oldest first.
POST /assets/{id}/valuations Record a value as of a date. Re-posting the same date updates that day's value (upsert) instead of erroring.
DELETE /assets/{id} Delete an asset and its whole valuation history.
GET /net-worth/summary Aggregated net worth: timeline, breakdown by class, by capital role, by risk level.

Create asset body:

{
  "name": "Brokerage account",
  "asset_class": "investments",
  "currency": "USD",
  "notes": null,
  "capital_role": "income",
  "monthly_cash_flow": 0,
  "risk_level": "high",
  "value": 25000,
  "as_of_date": "2026-08-29"
}

Add valuation body: {"value": 26500, "as_of_date": "2026-08-30"}.

GET /net-worth/summary — ?range= one of 7d, 30d, 90d, 180d, 365d, all (default 30d):

{
  "range": "30d",
  "current": "48250.00",
  "change_amount": "1200.00",
  "change_percent": 2.55,
  "series": [ { "date": "2026-08-01", "value": "47050.00" } ],
  "breakdown": [
    { "key": "cash", "name": "Cash", "color": "#...", "icon": "wallet", "amount": "5250.00", "percent": 10.88 }
  ],
  "capital_roles": [
    { "role": "income", "label": "Income", "color": "#...", "total_value": "25000.00", "monthly_cash_flow": "0.00", "count": 1 }
  ],
  "risk_levels": [
    {
      "risk_level": "low", "label": "Low", "color": "#...",
      "total_value": "5250.00", "percent": 10.88,
      "items": [ { "key": "cash", "name": "Cash", "amount": "5250.00", "percent": 100.0 } ]
    }
  ]
}

Crypto

Live-priced crypto holdings — a coin plus a quantity, kept current against CoinGecko's Demo API. Under the hood each holding is an Asset (asset_class=crypto, see Assets & Net Worth) — deleting one is DELETE /assets/{asset_id}, not a separate endpoint, and it shows up in /net-worth/summary like any other asset automatically.

Requires AURUM_COINGECKO_API_KEY in .env (a free Demo key, no card required). Endpoints that need CoinGecko return 400 with a message telling you so if it's unset.

Prices are never fetched in the background — there's no scheduler in this stack. Two triggers only: POST /crypto/refresh (a manual "refresh now"), and a lazy check on every GET /crypto/holdings that only actually calls CoinGecko once 24h have passed since the last successful sync. If CoinGecko is unreachable, existing values are left untouched and error_key is set instead of the whole request failing.

Method Path Description
GET /crypto/holdings List holdings with their live value. Also runs the lazy once-a-day auto-refresh.
POST /crypto/refresh Force a price refresh right now, bypassing the 24h window.
POST /crypto/holdings Add a holding — fetches today's price immediately so it isn't null until the next sync.
PATCH /crypto/holdings/{asset_id} Change quantity. Recomputes value from the last known price — does not call CoinGecko.
GET /crypto/search ?q= — search CoinGecko for a coin to add (name/ticker, returns its coingecko_id + logo).

Create body:

{
  "coingecko_id": "bitcoin",
  "symbol": "btc",
  "name": "Bitcoin",
  "thumb_url": "https://...",
  "quantity": "0.05"
}

coingecko_id is CoinGecko's own stable id (not the ticker — tickers collide across unrelated coins) — get it from /crypto/search rather than guessing. quantity supports up to 18 decimal places (wei-level token amounts).

GET /crypto/holdings / POST /crypto/refresh response:

{
  "synced": true,
  "last_synced_at": "2026-08-30T12:00:00Z",
  "error_key": null,
  "holdings": [
    {
      "asset_id": 7,
      "coingecko_id": "bitcoin",
      "symbol": "BTC",
      "name": "Bitcoin",
      "thumb_url": "https://...",
      "quantity": "0.05",
      "value": "3000.00",
      "unit_price": "60000.00",
      "as_of_date": "2026-08-30"
    }
  ]
}

value/unit_price are null only for a holding whose very first price fetch failed (CoinGecko was down right when it was added) — distinct from a real 0. error_key is "unreachable" when this sync attempt couldn't reach CoinGecko (existing values are kept as-is), or null otherwise.

Dashboard, Cash Flow & Reports

Read-only aggregation endpoints — the numbers behind Aurum's charts. Useful for pulling summary data into an external dashboard without recomputing it yourself.

Method Path Description
GET /dashboard/summary One month's headline numbers + spending by category. ?year=&month= (default: current month).
GET /cash-flow Income vs. expense, month by month. ?start_date=&end_date= (default: no bound, i.e. all history).
GET /reports/category-spending One category's spend over time. ?category_id= (required) &start_date=&end_date=.
GET /reports/category-ranking All categories ranked by total spend over a period. ?kind=expense|income (default expense) &start_date=&end_date=.

GET /dashboard/summary response:

{
  "year": 2026, "month": 8,
  "real_income": "5200.00", "spent": "3120.45", "net": "2079.55", "transferred_out": "500.00",
  "spending_by_category": [
    { "category_id": 4, "name": "Groceries", "color": "#22c55e", "icon": "shopping-cart", "amount": "612.30", "percent": 19.6 }
  ]
}

GET /cash-flow response:

{
  "start_date": null, "end_date": null,
  "points": [ { "year": 2026, "month": 7, "income": "5200.00", "expense": "3400.00", "net": "1800.00" } ],
  "total_income": "62400.00", "total_expense": "40800.00", "total_net": "21600.00"
}

Insights & Advice

Rules-based, computed on read — no ML, no background jobs. Both read from the same transaction/ account/asset data as everything else; thresholds are configurable via Settings.

Method Path Description
GET /insights/alerts Active proactive alerts (negative cash flow streak, declining net worth, over-budget category, risky allocation, idle cash).
GET /advice Plain-language observations (rising spending categories, unbudgeted top expenses, savings-rate trend).

Both return a key + machine-readable params per item rather than pre-rendered text — the frontend interpolates a localized message client-side, so build your own message from key/params if you're consuming this programmatically rather than trying to parse rendered strings.

// GET /insights/alerts
{ "alerts": [ { "key": "idle_cash", "severity": "warning", "params": { "account_id": 2, "days": 75 } } ] }

// GET /advice
{ "items": [ { "key": "rising_category", "tone": "warning", "params": { "category": "Dining", "percent": 34.2 } } ] }

Settings

App-wide configuration: display currency and every alert threshold used by /insights/alerts. Single row, created automatically on first run — there's nothing to create, only to read/update.

Method Path Description
GET /settings Read current settings.
PATCH /settings Update settings (partial).
{
  "currency": "USD",
  "negative_cash_flow_threshold_months": 2,
  "net_worth_decline_threshold_months": 2,
  "risky_allocation_threshold_percent": 20,
  "idle_cash_threshold_amount": "1000.00",
  "idle_cash_threshold_days": 60
}
  • currency: 3-letter uppercase code — display-only, no conversion.
  • negative_cash_flow_threshold_months / net_worth_decline_threshold_months: consecutive months before the corresponding alert fires (1–24).
  • risky_allocation_threshold_percent: max % of total capital allowed in medium/high risk tiers before risky_allocation_exceeded fires (1–100).
  • idle_cash_threshold_amount / idle_cash_threshold_days: balance + days of no activity a depository account needs to hit before idle_cash fires.

Backup & Restore

A full snapshot of every table as one JSON document — the same mechanism the in-app Settings → Backup & Restore uses. Good for scripted off-site backups, or for migrating data programmatically.

Method Path Description
GET /backup/export Download a full backup as JSON.
POST /backup/import Restore from a backup file — replaces existing data, all-or-nothing.
# Export
curl -u user:pass http://localhost:3000/api/backup/export -o aurum-backup.json

# Restore
curl -u user:pass -X POST http://localhost:3000/api/backup/import \
  -H "Content-Type: application/json" \
  -d @aurum-backup.json

The payload includes an aurum_backup_version field checked on import — a file from an incompatible future format is rejected outright rather than partially applied. Treat /backup/import as destructive: back up your current data first if you're experimenting.

Recipes

Add an expense transaction

curl -u user:pass -X POST http://localhost:3000/api/transactions \
  -H "Content-Type: application/json" \
  -d '{
    "account_id": 1,
    "category_id": 4,
    "type": "expense",
    "amount": 12.50,
    "description": "Coffee",
    "date": "2026-08-30"
  }'

Add income with a tag

# 1. Find or create the tag
curl -u user:pass -X POST http://localhost:3000/api/tags \
  -H "Content-Type: application/json" -d '{"name": "Freelance"}'
# -> {"id": 9, "name": "Freelance"}

# 2. Create the transaction referencing it
curl -u user:pass -X POST http://localhost:3000/api/transactions \
  -H "Content-Type: application/json" \
  -d '{
    "account_id": 1,
    "category_id": 2,
    "type": "income",
    "amount": 800,
    "description": "Client invoice",
    "date": "2026-08-30",
    "tag_ids": [9]
  }'

Move money between two of your own accounts

curl -u user:pass -X POST http://localhost:3000/api/transactions \
  -H "Content-Type: application/json" \
  -d '{
    "account_id": 1,
    "transfer_account_id": 2,
    "type": "transfer",
    "amount": 500,
    "description": "Move to savings",
    "date": "2026-08-30"
  }'

Pull this month's spend by category (for an external dashboard)

curl -u user:pass "http://localhost:3000/api/dashboard/summary" | jq '.spending_by_category'

Bulk-import transactions from your own data source

curl -u user:pass -X POST http://localhost:3000/api/transactions/bulk \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      { "account_id": 1, "type": "expense", "amount": 9.99, "description": "Spotify", "date": "2026-08-01" },
      { "account_id": 1, "type": "expense", "amount": 45.00, "description": "Gas", "date": "2026-08-03" }
    ]
  }'

For self-hosting, environment variables, and running Aurum itself, see README.md.