# Agent access

> Issue a bearer token so a local agent can call the Tidings API.

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

```bash
make agent-token LABEL='laptop-claude'
```

> Or run the `headless-backend-bootstrap` skill — 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:

```bash
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:

```bash
TOKEN=fin_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
curl -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

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.

```bash
curl -s https://gettidings.com/demo/api/v1/health | jq .status
curl -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](https://github.com/tvhahn/tidings/blob/master/docs/guides/static-hosted-demo.md#demo-api).

## Token lifecycle

Three Makefile targets cover the full lifecycle. Each wraps `scripts/agent_token.py` directly.

```bash
make agent-token LABEL='cursor-on-laptop'             # issue, default scope read+write
make agent-token LABEL='read-only-agent' SCOPE=read   # issue with strict-read scope
make agent-token-show                                  # list ids, labels, scopes, last-used
make agent-token-revoke ID=0123456789abcdef            # delete by id
```

`agent-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:33
```

Revocation is permanent — the row is removed from `data/config.json`. Any client still holding that token gets a 401 on its next request.

## 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

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)

These bypass auth so a fresh agent can discover the API before it has a token:

- `GET /api/v1/health` — liveness + last-activity probe
- `GET /openapi.json` — full OpenAPI schema
- `GET /docs` — Swagger UI
- `GET /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

### curl

```bash
TOKEN=fin_…
BASE=http://localhost:8000/api/v1

# Read this month's spending
curl -sH "Authorization: Bearer $TOKEN" "$BASE/summary?month=2026-04" | jq .

# Search transactions
curl -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)

```python
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"])
```

### n8n

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)

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.json` if 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

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:

```json
{
  "error": "invalid token",
  "code": "UNAUTHORIZED",
  "details": null
}
```

## Security caveats

Read this section before exposing the API on anything other than `localhost`.

- **Tokens grant full data access at their scope.** A `read+write` token can delete every transaction. Treat them like database passwords. Rotate when an agent or device is decommissioned. Run `make 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 `.gitignore` already excludes `data/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 `Authorization` header 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

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.

1. **CORS.** The default allowlist is `http://localhost:5173`. Set `CORS_ALLOWED_ORIGINS` to the origin(s) that will call the API. A wildcard works for read-only uses but should be considered carefully.

   ```bash
   CORS_ALLOWED_ORIGINS=https://tidings.example.com,http://192.168.1.74:5173
   # or, for development only
   CORS_ALLOWED_ORIGINS=*
   ```

2. **Bind address.** Inside the devcontainer the FastAPI server binds to `0.0.0.0:8000` already; confirm the docker-compose port mapping forwards `8000` to the host. If you ran `uv run uvicorn` directly with `--host 127.0.0.1`, switch to `--host 0.0.0.0`.

3. **TLS.** Run a reverse proxy that terminates HTTPS. Caddy is the lowest-friction option:

   ```caddyfile
   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

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

Three quick checks confirm the path end-to-end after issuing a token:

```bash
TOKEN=fin_…

# 1. No header → 401
curl -si http://localhost:8000/api/v1/categories | head -1
# HTTP/1.1 401 Unauthorized

# 2. Valid token → 200
curl -si -H "Authorization: Bearer $TOKEN" \
  http://localhost:8000/api/v1/categories | head -1
# HTTP/1.1 200 OK

# 3. Read token on a write → 403
curl -si -H "Authorization: Bearer $READ_ONLY_TOKEN" \
  -X DELETE http://localhost:8000/api/v1/transactions/foo/bar | head -1
# HTTP/1.1 403 Forbidden
```

If all three behave as shown, the auth layer is wired correctly.