Agent access
Tidings exposes a JSON HTTP API at /api/v1/* that a local agent (Claude Code, Cursor, n8n, curl, a custom script) can call directly. Authentication is RFC-6750 bearer tokens — Authorization: Bearer fin_… on every request. This guide walks through issuing a token, using it, and revoking it.
The bundled React dashboard does not need a token; it talks to http://localhost:8000 over loopback and is unauthenticated by default. Agents that connect from anywhere else — a second device on your LAN, an agent running in another container, an automation pipeline — go through the bearer channel.
Quickstart
Section titled “Quickstart”make agent-token LABEL='laptop-claude'Or run the
headless-backend-bootstrapskill — it wraps the detect → seed → token → snippet flow and prints the consumer-side curl/Python/MCP examples in one invocation. Useful when an agent (Claude Code, Claude Desktop, Cursor) is doing the install and you want one button to press.
Running the Docker stack rather than a dev checkout? make agent-token on
the host writes to the checkout’s data/, which the containers don’t read —
mint inside the container instead, so the hash lands in the named volume:
docker compose exec finance python -c \ "from src.finance.agent_tokens import add_token; rec, raw = add_token(label='laptop-claude'); print(raw)"The command prints the token once. Save it now; only the sha256 hash is persisted to data/config.json, so the raw value is never recoverable.
Token: fin_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (illustrative — not a real token) id: 0123456789abcdef scope: read+write label: laptop-claude
Save this token now — it will NOT be shown again.Pass it as `Authorization: Bearer <token>` on /api/v1/* requests.Test the token against any read endpoint:
TOKEN=fin_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxcurl -sH "Authorization: Bearer $TOKEN" http://localhost:8000/api/v1/categories | jq .A 200 with the category list means the token works.
Try it without installing
Section titled “Try it without installing”The demo journal is served read-only at a public endpoint, so an agent can explore the API before you clone anything. The base URL is https://gettidings.com/demo/api/v1 — the same routes as a self-hosted install, backed by the fictional Mira Lin Chen fixtures.
curl -s https://gettidings.com/demo/api/v1/health | jq .statuscurl -s 'https://gettidings.com/demo/api/v1/summary?month=2026-03' | jq .The demo world covers 2025-05 through 2026-03, with demo-today pinned to 2026-03-19. All of it is fictional data. No token is needed — an Authorization header is accepted and ignored, so the demo never returns a 401. Writes are not part of the demo: any non-GET method returns a 405 pointing you at self-hosting for a writable journal. For how the endpoint is served and regenerated, see the static hosted demo guide.
Token lifecycle
Section titled “Token lifecycle”Three Makefile targets cover the full lifecycle. Each wraps scripts/agent_token.py directly.
make agent-token LABEL='cursor-on-laptop' # issue, default scope read+writemake agent-token LABEL='read-only-agent' SCOPE=read # issue with strict-read scopemake agent-token-show # list ids, labels, scopes, last-usedmake agent-token-revoke ID=0123456789abcdef # delete by idagent-token-show prints the table without raw values:
id label scope created_at last_used_at----------------------------------------------------------------------------------0123456789abcdef laptop-claude read+write 2026-04-30T19:14:02 —9ab21f0c7e... read-only-agent read 2026-04-30T19:15:10 2026-04-30T19:16:33Revocation is permanent — the row is removed from data/config.json. Any client still holding that token gets a 401 on its next request.
Scopes
Section titled “Scopes”Two scopes ship today; future scopes are a one-line addition in src/api/auth.py.
| Scope | Allows | When to use |
|---|---|---|
read | GET /api/v1/* | A third-party UI, a sketchy agent you want to box in, a read-only dashboard mirror. |
read+write | All methods on /api/v1/* | The default. Day-to-day agent work — categorize transactions, set overrides, import a statement. |
The default at issuance is read+write because Tidings is single-user; gating writes behind a separate token rotation is friction without a security story. The read scope is the safer hand-out when the holder isn’t fully trusted.
Versioning
Section titled “Versioning”The path prefix is /api/v1/. Breaking changes are allowed inside v1, lockstep with the INSTALL.md clone tag — no external consumer is depending on a stable contract today, so the prefix is hygiene rather than a guarantee. If you build against this API, pin to the INSTALL.md clone tag and use the committed openapi.json as your change log; make verify-openapi is the drift gate. The day a consumer needs a stability promise, /api/v2/ ships alongside; until then there is one moving target. See docs/specs/01_backend-as-platform/PLAN.md “Versioning policy” (a local-only spec, absent in the public repo) for the full rationale.
Public endpoints (no token required)
Section titled “Public endpoints (no token required)”These bypass auth so a fresh agent can discover the API before it has a token:
GET /api/v1/health— liveness + last-activity probeGET /openapi.json— full OpenAPI schemaGET /docs— Swagger UIGET /redoc— ReDoc UI
Everything else under /api/v1/* requires a valid token whenever any token is configured. If agent_tokens is empty (the default after docker compose up), the middleware no-ops and the API is reachable without a header — preserving the zero-config localhost quickstart.
Worked examples
Section titled “Worked examples”TOKEN=fin_…BASE=http://localhost:8000/api/v1
# Read this month's spendingcurl -sH "Authorization: Bearer $TOKEN" "$BASE/summary?month=2026-04" | jq .
# Search transactionscurl -sH "Authorization: Bearer $TOKEN" \ "$BASE/transactions/search?merchant=tim+hortons" | jq '.transactions[] | {date, amount, category}'
# Pin a category override (company name in the path, URL-encoded; category in the body)curl -sH "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -X PUT "$BASE/overrides/WHOLE%20FOODS" \ -d '{"category":"Groceries"}'
# Search transactions by an array of merchants (POST sibling of the GET)curl -sH "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -X POST "$BASE/transactions/search-by-filter" \ -d '{ "from_month": "2026-01", "to_month": "2026-04", "merchant_in": ["whole foods", "trader joe", "costco"], "category_in": ["Groceries"], "min_amount": 10 }' | jq '.summary'The POST transactions/search-by-filter endpoint is the agent-friendly companion to GET /transactions/search — same filter semantics, but merchant_in / category_in / institution_in / type_in accept arrays in the request body instead of needing each value URL-encoded as a separate query param.
Python (httpx)
Section titled “Python (httpx)”import httpx, os
client = httpx.Client( base_url="http://localhost:8000/api/v1", headers={"Authorization": f"Bearer {os.environ['FINANCE_API_TOKEN']}"},)
summary = client.get("/summary", params={"month": "2026-04"}).raise_for_status().json()print(summary["total_spent"])Use the HTTP Request node:
- URL:
http://localhost:8000/api/v1/summary?month={{ $now.format('yyyy-MM') }} - Authentication: Header Auth (or Generic Credential Type → Header Auth)
- Header name:
Authorization - Header value:
Bearer fin_…
Store the token in n8n’s credential vault, not in the workflow JSON.
Custom HTTP-tool-using agents (Claude API tool use, ChatGPT actions, Cursor tools)
Section titled “Custom HTTP-tool-using agents (Claude API tool use, ChatGPT actions, Cursor tools)”Any agent surface that lets you define an HTTP tool can call this API. Configure the tool with:
- Base URL:
http://localhost:8000(or your reverse-proxy host) - Authentication: static header —
Authorization: Bearer fin_… - Spec: point the agent at
http://localhost:8000/openapi.jsonif it consumes OpenAPI; otherwise hand-write the tool schema for the endpoints you need.
A native MCP server entry-point (finance-mcp for Claude Desktop’s stdio config) is on the roadmap but not yet shipped. Until then, use the HTTP path above — most agents handle bearer-auth REST cleanly.
Errors
Section titled “Errors”The API returns the unified {error, code, details} shape on all 4xx/5xx responses.
| Status | code | When |
|---|---|---|
| 401 | UNAUTHORIZED | Missing Authorization header, wrong scheme, or token not in data/config.json. |
| 403 | FORBIDDEN | Token is valid but its scope does not permit this (method, path). For example, a read token attempting POST /api/v1/transactions. |
Body for both:
{ "error": "invalid token", "code": "UNAUTHORIZED", "details": null}Security caveats
Section titled “Security caveats”Read this section before exposing the API on anything other than localhost.
- Tokens grant full data access at their scope. A
read+writetoken can delete every transaction. Treat them like database passwords. Rotate when an agent or device is decommissioned. Runmake agent-token-revoke ID=…whenever a token leaks. - Never commit a token. They go in
.env, in the agent’s credential store, or in your password manager. The repo’s.gitignorealready excludesdata/config.json, but the raw value never lives there anyway. - Loopback is the assumed boundary. Tidings has no rate limiting, no IP allowlisting, no origin allowlisting beyond CORS. Anything stronger is the responsibility of the network you put it on.
- TLS is the responsibility of the reverse proxy, not the app. The FastAPI server speaks plaintext HTTP. Do not bind it to a public interface without a TLS terminator (Caddy, Traefik, nginx, Cloudflare Tunnel) in front. Bearer tokens travel in the
Authorizationheader and are useless on the wire without TLS. - Hash storage protects the disk, not the wire. Tokens are sha256-hashed at rest, but the raw value travels with every request. If TLS is misconfigured, anyone on the path sees the token.
Exposing the API on a LAN
Section titled “Exposing the API on a LAN”The defaults are tight on purpose. To expose the API to other devices (your phone over Tailnet, a household member’s laptop, a separate container in the same compose project), three pieces have to line up.
-
CORS. The default allowlist is
http://localhost:5173. SetCORS_ALLOWED_ORIGINSto the origin(s) that will call the API. A wildcard works for read-only uses but should be considered carefully.Terminal window CORS_ALLOWED_ORIGINS=https://tidings.example.com,http://192.168.1.74:5173# or, for development onlyCORS_ALLOWED_ORIGINS=* -
Bind address. Inside the devcontainer the FastAPI server binds to
0.0.0.0:8000already; confirm the docker-compose port mapping forwards8000to the host. If you ranuv run uvicorndirectly with--host 127.0.0.1, switch to--host 0.0.0.0. -
TLS. Run a reverse proxy that terminates HTTPS. Caddy is the lowest-friction option:
tidings.example.com {reverse_proxy localhost:8000}Cloudflare Tunnel is a valid alternative if you want a stable public hostname without opening a port.
Once those three are in place, every request from a non-loopback origin must carry a valid token. make agent-token is the right answer; do not poke holes in CORS to skip auth.
How auth interacts with the bundled dashboard
Section titled “How auth interacts with the bundled dashboard”The dashboard has its own cookie-session channel, separate from bearer tokens. With no password set (app_password_hash absent from data/config.json), the app runs in TOFU mode: the dashboard works unauthenticated and prompts you to set a password under Settings → Password. Once a password is set, the dashboard authenticates via POST /api/v1/auth/login and rides a signed session cookie; bearer tokens remain the channel for agents and scripts. If you expose the API beyond loopback, set a password first — TOFU mode plus a reachable port is an open dashboard.
Verification
Section titled “Verification”Three quick checks confirm the path end-to-end after issuing a token:
TOKEN=fin_…
# 1. No header → 401curl -si http://localhost:8000/api/v1/categories | head -1# HTTP/1.1 401 Unauthorized
# 2. Valid token → 200curl -si -H "Authorization: Bearer $TOKEN" \ http://localhost:8000/api/v1/categories | head -1# HTTP/1.1 200 OK
# 3. Read token on a write → 403curl -si -H "Authorization: Bearer $READ_ONLY_TOKEN" \ -X DELETE http://localhost:8000/api/v1/transactions/foo/bar | head -1# HTTP/1.1 403 ForbiddenIf all three behave as shown, the auth layer is wired correctly.