One agent doc per repo, and it is CLAUDE.md. Two docs describing one repo drift, and the one nobody read is always the one holding the rule that mattered. Written fresh rather than reformatted, so the structure follows what someone working here actually needs. Two rules from the old file are gone deliberately. The mandate to branch for every change was retired in favour of one linear-history policy, and the release snippet used `git add -A`, which sweeps in whatever else is dirty. The architecture section is the part worth reading. An earlier draft called src/auth, src/controllers, src/clients, src/dns and src/models dead code, derived from grepping main.py's imports. That was wrong: main.py:55 calls initialize_oidc(), which imports and configures src.auth.oidc from inside the function body, so src/auth is configured with live Authentik issuers on every boot. It also missed four genuinely unreferenced packages. The section now states the method used -- import the app in the container and read sys.modules -- and its blind spot, that a cold snapshot cannot see a module imported on a request path. Also records that the README's "runs as non-root user (uid 1000)" is false: the Dockerfile has no USER directive. Flagged rather than fixed, since changing the runtime user is not a docs change. Co-Authored-By: Claude <noreply@anthropic.com>
8.6 KiB
CLAUDE.md — core-api
FastAPI service providing infrastructure management, home automation, and utility endpoints for the homelab. Talks to Portainer, Nginx Proxy Manager, Home Assistant, Postgres (via SQLAlchemy async + Alembic), Qdrant, and Authentik (OIDC). Deployed on tower-of-joy at :8083.
Ports — these differ, deliberately
| Port | How | |
|---|---|---|
| Local dev | 8788 | ./wakeup.sh, uvicorn --reload, logs to logs/server.log |
| Production | 8083 | container; health at http://192.168.86.149:8083/health |
Testing localhost:8083 on the dev box hits the container, not your reload server.
Live contract
The live contract is always http://localhost:8083/openapi.json (62 paths, verified
2026-08-09) and human docs at http://localhost:8083/docs / /redoc — generated from
running code, so query it rather than inferring routes from source or from the README's
endpoint list, which can drift.
Architecture
Domain-first layout under src/domains/<name>/{controller,models,schemas,service}.py
(auth, dashboard, health, housekeeping, infrastructure, static, tools). src/main.py
wires only src.domains.* — verify by reading its imports.
Legacy top-level packages — "not in main.py" does not mean dead. Routes are wired
only from src.domains.*, so grepping main.py's imports looks like it settles which
packages are live. It does not. main.py:55 calls initialize_oidc(), and that function
(src/shared/security.py:21) deliberately imports and configures both src.auth.oidc
and src.domains.auth.oidc — a function-body import, invisible to a grep of main.py.
That one call drags in src/auth/, src/controllers/, src/db/, src/logging_config.py
and src/base_schema.py at startup.
Three tiers, established by importing the app inside the container and reading
sys.modules (verified 2026-08-09):
| Tier | Packages |
|---|---|
| Serving routes | src/domains/, src/shared/, src/service_groups/ |
| Loaded and configured, but serving no routes | src/auth/, src/db/, src/controllers/, src/logging_config.py, src/base_schema.py |
| Genuinely unreferenced | src/agent/, src/api/, src/clients/, src/dns/, src/memory/, src/models/ |
src/auth/ is the trap. Its oidc_config singleton is configured at every startup with
the real Authentik issuers — the log line src.auth.oidc:configure proves it — so a test
importing src.auth.oidc is exercising live, configured code, not a fossil. No src/domains/*
module depends on it, so it is configured defensively rather than used; that makes it a
deletion candidate, but a considered one, not obvious cleanup.
Before deleting anything from src/, import the app and read sys.modules rather than
grepping main.py. Function-body imports exist here specifically to dodge circular imports,
and they are exactly what a grep misses.
That check has its own blind spot, so do not read the third tier as a delete list. The
table above is a snapshot taken after a cold import src.main — it shows what startup
loads. A module imported inside a request handler would be absent from it while being
entirely live, and absence would then be a timing artifact rather than evidence of death.
This bit on webber, where a tool package imported from inside an agent method looked
unloaded and was serving every request. Nothing in core-api is currently known to work that
way, but that is the weaker claim — it means nobody has exercised the routes and re-checked,
not that nobody does it. Before deleting a third-tier package, drive the endpoints that
would plausibly load it and take the snapshot again.
Some tests (test_auth_controller.py, test_oidc.py, test_npm_client.py,
test_portainer_client.py, test_static_controller.py, test_tools_controller.py) import
from the top-level paths rather than src.domains.*. Which of those cover live code follows
the table above — test_oidc.py does; the client tests target the unreferenced tier. Not
cleaned up in this pass; flagged, not fixed.
Shared infra (config, database, logging, security/OIDC, external API clients) lives in
src/shared/.
Group new work by domain, not by file type — a single large routers/ folder is
the thing to avoid. Reference: FastAPI best practices.
Database
SQLAlchemy 2.0 async + asyncpg, migrations via Alembic (alembic/versions/). Models
live under src/domains/<name>/models.py and must be imported in alembic/env.py to
register with Base.metadata — check that file when adding a new model or alembic revision --autogenerate will silently miss it.
Working here
Plan, act, reflect. Outline which files you will touch and the side effects before writing. Change in small atomic steps. Afterwards, verify: did existing tests break, and does the new behaviour have a test?
Test locally first — the build-deploy loop is slow. ./wakeup.sh auto-reloads on
code changes (not on requirements.txt changes; restart the container/script after
adding a dependency). Deploy only when a feature is complete and tested.
Run tests through the venv explicitly, to avoid environment mismatch:
.venv/bin/python -m pytest tests/
# or, with coverage:
.venv/bin/python -m pytest --cov=src --cov-report=term-missing
Copy .env.example to .env and configure Portainer, NPM, Home Assistant, SearXNG,
Postgres, and Qdrant hosts/credentials.
No linter is configured in this repo (no ruff/flake8 config, none in requirements.txt
or dev-requirements.txt) — unlike some sibling repos, don't assume ruff check exists
here.
CI
.gitea/workflows/build.yml is the only workflow: triggered on v* tag push, it
creates a Gitea release, builds and pushes the image, then pings Watchtower. There is
no CI test/lint gate — pytest only runs locally or on request. Verify tests pass
before tagging a release.
Work tracking
Work lives in pql, not a markdown TODO. This repo's vault is standalone — its tickets
and its internal decisions live here in .pql/ and governance/, and travel with a clone,
because .pql/changelog/ is committed and replayed by the git hooks (D-15). The databases are
gitignored and rebuildable with pql plan rebuild.
pql is not on the non-interactive PATH — invoke it as
/home/jpmschweitzer/.local/bin/pql. From inside this repo no --vault is needed: pql anchors
at the nearest .git/ ancestor, which is this repo.
/home/jpmschweitzer/.local/bin/pql ticket list # this repo's open work
/home/jpmschweitzer/.local/bin/pql plan whatsnext # next unblocked item, with context
/home/jpmschweitzer/.local/bin/pql decisions list # this repo's own decisions
Stack-level decisions that constrain this service live in the workspace vault and need the flag:
/home/jpmschweitzer/.local/bin/pql --vault /mnt/media/Projects decisions list --domain core-api
Note ticket new --decision D-N resolves ids within one vault, so a ticket here cannot link
to a workspace decision. Cite the id in the ticket body instead.
Do not add a TODO section to a markdown file.
Git
- History is linear — no merge commits. Work on
main, or a short-lived branch that is fast-forwarded and deleted. (This repo's AGENTS.md previously mandated a feature branch for every change; that rule was retired workspace-wide on 2026-08-08 and does not apply here anymore.) - Conventional Commits:
feat:,fix:,refactor:,docs:,chore:. - Atomic commits — one logical change each.
- Stage explicitly. Never
git add -A— it is denied by policy, and it sweeps in whatever else is dirty, including secrets. - Update
CHANGELOG.mdwith every user-facing change, under[Unreleased]inAdded/Changed/Fixed.
Releasing
Ask whether a deploy is wanted first — it is not automatic.
- Bump the version in
pyproject.toml(patch for fixes, minor for features). - Move
[Unreleased]entries into a dated version section inCHANGELOG.md. - Stage the changed files by name, commit, tag
vX.Y.Z,git push origin main --tags. - Gitea CI (
build.yml) builds and pushes the image on the tag; Watchtower deploys it. - Verify:
curl http://192.168.86.149:8083/health.
Security
- OIDC authentication via Authentik, multi-issuer/multi-audience support.
- Admin endpoints require authentication when
OIDC_ENABLED=true. - README claims the container "runs as non-root user (uid 1000)" — checked and
false: the Dockerfile has no
USERdirective, so the container runs as root. Not fixed here (out of scope for a docs normalization pass); flagging so it isn't restated as fact.