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'Working in this checkout with Claude Code? The
headless-backend-bootstrapskill runs this whole guide’s setup in one invocation — it detects Docker-vs-dev-checkout, mints the token via the right path (including the restart the running process needs), verifies the auth contract, and prints the consumer snippets.
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)"docker compose restart financeThe restart matters: the API loads tokens at startup, so a token minted via exec answers 401 until the finance container restarts. (Tokens minted from the app itself take effect immediately; only this out-of-band path needs the restart.)
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/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.
last_used_at is stamped on successful bearer auth, throttled to once per
token per 15 minutes (each stamp rewrites data/config.json), so it reads as
“active recently”, not per-request telemetry. For per-write accountability see
Every write is on the record.
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.
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 UIGET /llms.txt— a plain-text orientation file agents can read first
For everything else under /api/v1/*: a request that presents an Authorization header is always validated against the configured tokens (invalid token → 401, insufficient scope → 403). A request with no header falls through to the browser channels — session cookie, or TOFU. That means anonymous access stays open until a dashboard password is set, even if tokens exist; setting a password (Settings → Password) is what closes it. With no password and no tokens — the state right after docker compose up — the API is fully open on loopback, preserving the zero-config 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.
There is no native MCP server entry-point; use the HTTP path above — most agents handle bearer-auth REST cleanly. (Direction lives in ROADMAP.md.)
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 PATCH /api/v1/transactions/bulk. |
Body for both:
{ "error": "invalid token", "code": "UNAUTHORIZED", "details": null}Every write is on the record
Section titled “Every write is on the record”Every mutating call to /api/v1/* is journaled in an append-only activity
ledger: who made it (token id and label, browser session, or the pre-password
device), which operation, when, and — for the invertible operations
(transaction category/state/comment/field edits, bulk recategorization,
overrides, merchant aliases, budget config, category groups) — a before/after
image with one-call undo. Three endpoints and one page expose it:
-
GET /api/v1/whoami— the caller’s resolved identity: kind, token id, label, scope,last_used_at. The quickest “is my token wired up?” check. -
GET /api/v1/activity— the journal, newest first. Filters:limit(default 100, max 500),since(inclusive ISO timestamp),operation, andprincipal— a token id, or the literalmefor “my own writes”. An agent can read back its own mutations to self-verify they landed:Terminal window curl -s -H "Authorization: Bearer $TIDINGS_TOKEN" \"http://localhost:8000/api/v1/activity?principal=me&limit=10" -
POST /api/v1/activity/{id}/revert— undo an invertible entry. Refuses with409 stale_revertif the resource changed after the entry was written (passforce=trueto override), 409 if already reverted or not reversible. The revert is itself journaled and the original entry is marked. -
Settings → Activity — the same journal as a calm feed, grouped by principal and burst, with per-entry revert.
Entries are retained for 90 days on both backends (SQLite prune-on-write,
DynamoDB TTL). last_used_at on tokens is stamped at most once per 15 minutes
per token — treat it as coarse presence, not an audit timestamp; the ledger is
the audit trail.
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. The activity ledger (above) means a misbehaving token is at least visible and its instrumented edits are revertible — but permanent deletes and import commits are not, so the warning stands. - 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. Check 1 assumes a dashboard password has been set — in TOFU mode (no password yet) anonymous requests are allowed by design and it returns 200:
TOKEN=fin_…
# 1. No header → 401 (requires a dashboard password to be set; 200 in TOFU mode)curl -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.