Personal AI assistant log aggregator. lethe is a small, single-binary Go
service that ingests turn-level NDJSON from AI assistant collectors (Claude
Code, opencode, etc.), stores it in SQLite, and exposes a JSON API for
listing and reading sessions.
Search and the collector binary live in sibling repos / tasks
(lethe-collector-claude-code, lethe-search-and-opencode); this repo
is just the server.
?owner=.127.0.0.1 bind behind a reverse proxy.cp config.example.yaml config.yaml
just run
# or, for hot reload during development:
just air
The server reads config.yaml by default. Pass -config <path> to override.
lethe always binds 127.0.0.1. It is not safe to expose it to the
network directly. All authentication assumes a trusted reverse proxy on
the same host (e.g. Caddy or Traefik) that terminates TLS and either
injects auth headers or relays an OIDC bearer.
The implicit assumption is that nothing else on the host binds
127.0.0.1 and forges auth headers. That is the whole trust boundary
for path (a) below.
There are two independent auth paths, each gated by the
auth.allowed_users allowlist as a defense-in-depth check. If both are
enabled and a request carries both an Authorization header and the
configured forward-auth header, the bearer is validated first; the
header is only consulted if bearer validation fails.
Remote-UserThe reverse proxy runs Authelia forward-auth on the lethe vhost. On
success Authelia signs the user identity into headers (Remote-User,
Remote-Email, Remote-Groups) which the proxy forwards to lethe.
lethe reads the configured auth.forward_auth.user_header (default
Remote-User), checks the allowlist, and 403s on miss.
Sample Caddy snippet (/etc/caddy/Caddyfile):
lethe.example.com {
forward_auth http://127.0.0.1:9091 {
uri /api/verify?rd=https://auth.example.com
copy_headers Remote-User Remote-Email Remote-Groups Remote-Name
}
reverse_proxy 127.0.0.1:8080
}
lethe config:
auth:
allowed_users: ["bigbes"]
forward_auth:
enabled: true
user_header: "Remote-User"
Sample request (the proxy injects the headers on your behalf — this curl shows what reaches lethe, not what you'd send from a browser):
curl -H 'Remote-User: bigbes' http://127.0.0.1:8080/api/v1/sessions
lethe is registered as an OIDC client of Authelia. lethe only
validates tokens; it never issues them. The bearer must already be
obtained out-of-band by whatever client wants to call the API (the
collector, scripts, etc.).
Sample Authelia client entry
(identity_providers.oidc.clients in configuration.yml):
identity_providers:
oidc:
clients:
- client_id: lethe
client_name: Lethe
client_secret: '<argon2 hash>'
public: false
authorization_policy: two_factor
redirect_uris:
- https://lethe.example.com/oauth2/callback
scopes:
- openid
- profile
- email
userinfo_signing_algorithm: none
token_endpoint_auth_method: client_secret_basic
lethe config (note: only the issuer URL, audience, and the username
claim — no client secret on the lethe side, since lethe never starts a
flow):
auth:
allowed_users: ["bigbes"]
oidc:
enabled: true
issuer: "https://auth.example.com"
audience: "lethe"
username_claim: "preferred_username"
Sample request:
curl -H "Authorization: Bearer $TOKEN" \
https://lethe.example.com/api/v1/sessions
/healthz, /readyz, and /metrics are mounted outside the auth
middleware so the host can scrape them locally without going through
the proxy. Everything under /api/v1/* is authed.
| Method | Path | Auth | Notes |
|---|---|---|---|
| POST | /api/v1/ingest |
yes | NDJSON body of TurnEvent records. Idempotent at the turn level per owner. |
| GET | /api/v1/sessions |
yes | Paginated. Filters: tool, host, since, until. Admins may pass ?owner=<user> or ?owner=*. |
| GET | /api/v1/sessions/{tool}/{host}/{session_id} |
yes | Full session with turns inline. Admins may pass ?owner=<user>. |
| GET | /healthz |
no | Liveness. |
| GET | /readyz |
no | Readiness — DB ping etc. |
| GET | /metrics |
no | Prometheus. |
The owner field is server-derived; the wire format has no owner. A
non-admin caller passing ?owner= gets 403.
Errors are RFC 7807 application/problem+json.
lethe binds 127.0.0.1 only. Run it on the same host as a reverse
proxy that terminates TLS and injects auth headers (or relays an OIDC
bearer). Do not expose the listener to the network directly.
When run via the bundled docker-compose.yml, the service does not
publish a port to the host — it only exposes 8080 on the compose
network. The reverse proxy reaches lethe through that network.
The SQLite DB is the only state. Take consistent online backups with
sqlite3 .backup. Sample cron with 14-day retention:
# /etc/cron.d/lethe-backup
# Daily online backup of the lethe SQLite database, 14-day retention.
PATH=/usr/bin:/bin
15 03 * * * lethe \
ts=$(date +\%Y\%m\%d-\%H\%M\%S); \
sqlite3 /var/lib/lethe/lethe.db ".backup '/var/backups/lethe/lethe-$ts.db'" && \
find /var/backups/lethe -name 'lethe-*.db' -mtime +14 -delete
/var/backups/lethe should live on a different volume from the live DB.
.
├── cmd/lethe/ # main, thin shell
├── internal/
│ └── shared/wire/ # locked NDJSON contract shared with the collector
├── config.example.yaml
├── Justfile
├── .air.toml
├── Dockerfile
├── docker-compose.yml
└── .golangci.yml
The rest of the layout (internal/config, internal/server,
internal/domain/..., internal/platform/..., migrations/) lands in
later phases.