One agent doc per repo, and it is CLAUDE.md. This repo carried two -- one at the root and one under webber-api/ -- which is the drift problem in its purest form: two documents, one subject, and no way to know which the last reader trusted. Written fresh rather than reformatted. README.md linked to webber-api/AGENTS.md, so that pointer moves with the file rather than dangling. The architecture section states the method used to establish what is live -- import the app inside the container and read sys.modules -- and then the case where that method fails here. src/domains/tools is absent from a cold snapshot and is entirely live: each agent's _register_tools imports its tool package from inside the method body, on every /agents/run. Absence from a snapshot taken before any request is served is a timing artifact, not evidence of death, and deleting on that basis would have removed the tool layer. Co-Authored-By: Claude <noreply@anthropic.com>
9.9 KiB
CLAUDE.md — webber
Local-LLM multi-agent development assistant — "similar to Claude Code but running locally"
(webber-api/docs/architecture.md), backed by Ollama via PydanticAI. Logical monorepo, single
.git, three subprojects: webber-api/ (FastAPI server, deployed), webber-cli/ (Typer CLI
client), webber-sandbox/ (swappable test project used by sandbox.sh, not shipped).
Ports
| Port | How | |
|---|---|---|
| Local dev | 8095 | cd webber-api && ./wakeup.sh, uvicorn --reload, logs to webber-api/logs/server.log |
| Production | 8086 | container webber, confirmed running (docker ps) on docker-dataplane |
wakeup.sh refuses to start if 8095 is already bound — it does not silently pick another
port. Testing localhost:8086 on the dev box hits the container, not your reload server.
Live contract
http://localhost:8086/openapi.json — 10 paths, version: 1.0.1 (verified 2026-08-09,
matches webber-api/pyproject.toml and the live /health response). Human docs at
http://localhost:8086/docs. Query the live spec rather than inferring routes from source —
src/domains/router.py currently has two routers commented out (see Architecture), so a
source read alone will overcount if you don't check whether an include is live.
/, /health, /agents/, /agents/run, /agents/stream, /agents/{agent_type},
/conversations/, /conversations/{conversation_id},
/conversations/{conversation_id}/messages, /conversations/{conversation_id}/save
Architecture
Domain-first layout under webber-api/src/domains/<name>/. src/main.py includes exactly one
router, src.domains.router.root_router, which composes the domain routers. A full directory
map lives in webber-api/docs/architecture.md — read that before adding a domain rather than
duplicating it here.
How liveness below was established: docker exec webber python3 -c "import src.main; import sys; print(sorted(m for m in sys.modules if m.startswith('src.')))" — i.e. importing the real
app inside the running container and reading sys.modules, not grepping main.py. Re-run that
command to re-check; a grep of imports will miss function-body imports, and this repo has one
that matters.
- Wired at startup, serving routes:
src.domains.health,src.domains.agents(router +explore/plan/taskagent packages),src.domains.conversations,src.shared.*,src.ollama,src.db(imported both byconversations/router.pyat module scope and bymain.py's lifespan shutdown handler). - Present in source, explicitly disabled:
src/domains/router.pyhas# from src.domains.auth.router import router as auth_routerand the equivalent fortools_router— both commented out with the include calls also commented out.src/domains/auth/is just an empty__init__.py. This one is dead — the disabling is visible in the same file, not a matter of tracing an indirect import. - The trap:
src/domains/tools/is not insys.modulesright afterimport src.main, but it is not dead.src/domains/agents/{explore,plan,task}/agent.pyeach have a method (e.g.PlanAgent._register_tools) that doesfrom src.domains.agents.plan.tools import register_plan_toolsinside the function body, called every time that agent is constructed — i.e. on every/agents/runor/agents/streamrequest for that agent type. That nested module then imports the real tool classes fromsrc.domains.tools.file,.search,.shellat module scope. A static snapshot taken before any request is served will not showsrc.domains.toolsloaded; that is a timing artifact, not evidence it is unused. Don't deletesrc/domains/tools/on the strength of asys.modulescheck alone — confirm by hitting/agents/runand re-checking, or by tracing the call graph from each agent's_register_tools. src/cli/is the implementation behindwebber-cli'spyproject.tomlscript entry — it is a separate Typer app, not imported by the API (src.main) at all. Its liveness is "is the CLI installed and invoked", not "is it wired into the API process".
Group new work by domain, not file type — webber-api/docs/fastapi-best-practices.md is the
house reference (mirrors the convention used across the other in-house FastAPI services here).
Database
SQLite by default (database_url = "sqlite+aiosqlite:///./webber.db" in
src/shared/config.py), not Postgres — confirmed by reading src/shared/config.py and
src/db/database.py (the latter's docstring says the pattern is ported from core-api, but
the backend differs). Models under webber-api/src/domains/<name>/models.py import Base
from src/db/models.py. No Alembic here (unlike core-api) — did not find a migrations
directory; unverified whether schema changes have any managed migration path at all. Check
before assuming one exists.
Working here
Test locally first. cd webber-api && ./wakeup.sh auto-reloads on code changes (not on
requirements.txt changes — restart after adding a dependency). Deploy only once a feature
is complete and tested.
cd webber-api
.venv/bin/python -m pytest tests/ # all tests
.venv/bin/python -m pytest tests/ -v --cov # verbose + coverage
.venv/bin/python -m pytest tests/test_tools.py -v # single file
webber-api/pyproject.toml declares [tool.ruff] and [tool.mypy] — unlike core-api, this
repo does have ruff/mypy config; whether either runs in CI is a separate question (see CI below
— it does not).
Copy webber-api/.env.example to webber-api/.env. Notable defaults: OLLAMA_URL points at
192.168.86.149:11434 (the host's Ollama, not a container), OLLAMA_AGENT_MODEL=gemma4:e2b,
optional Tatlock integration via TATLOCK_API_URL/INTERNAL_API_KEY, optional SearXNG via
SEARXNG_URL for the web_search tool.
Sandbox
webber-sandbox/ is a disposable project used to exercise the agents end-to-end, managed by
./sandbox.sh {list,load,reset,save,status} from the repo root. sandbox-templates/ holds the
reusable templates (calculator-cli has intentionally-seeded bugs for testing Explore/Task).
This directory is fixture material, not shipped code — do not treat bugs in it as real bugs.
CLI
webber-cli/ is a Typer client (webber-cli status|chat|explore|sessions|config) with tab
completion, session persistence (~/.webber_history, ~/.webber/config.toml), and three chat
modes (plan read-only, default, auto_accept). It talks to the API over HTTP — it does not
share a process with webber-api. Run it from its own venv: cd webber-cli && .venv/bin/webber-cli status.
CI
.gitea/workflows/build-api.yml triggers only on api/vX.Y.Z tags: creates a Gitea release,
builds/pushes git.schweitz.net/jpmschweitzer/webber-api, then pings Watchtower.
build-cli.yml triggers on cli/vX.Y.Z tags but is a placeholder — it only echoes a TODO, it
does not build or publish anything. No test or lint gate runs in CI for either package —
pytest and ruff only run locally or on request. Verify tests pass before tagging.
Work tracking
Work lives in pql, not a markdown TODO or docs/COVERAGE.md. Tickets and decisions for
this repo live in the workspace vault; this repo's .pql/ and governance/ trees stay
empty (D-15).
Two things must be spelled out on every invocation from in here, and each fails differently:
pqlis not on the non-interactivePATH— use/home/jpmschweitzer/.local/bin/pql.--vault /mnt/media/Projectsis mandatory. pql anchors a vault at the nearest.git/ancestor, and this repo is one, so a bare call resolves to this repo's empty vault. Reads return nothing; a write creates a stray vault and starts ticket ids at T-1, colliding with the real ones.
/home/jpmschweitzer/.local/bin/pql --vault /mnt/media/Projects ticket list
/home/jpmschweitzer/.local/bin/pql --vault /mnt/media/Projects plan whatsnext
/home/jpmschweitzer/.local/bin/pql --vault /mnt/media/Projects decisions list --domain webber
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'sAGENTS.mdpreviously mandatedfeature/...orfix/...branches for every change and forbade committing tomaindirectly — 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— denied by policy; it sweeps in whatever else is dirty, including secrets. - Each package versions independently via prefixed tags (
api/vX.Y.Z,cli/vX.Y.Z) and its ownCHANGELOG.md(webber-api/CHANGELOG.md,webber-cli/CHANGELOG.md); the rootCHANGELOG.mdis just an index pointing at both.
Releasing (API)
Ask whether a deploy is wanted first — it is not automatic.
- Bump the version in
webber-api/pyproject.toml. - Move
[Unreleased]entries into a dated version section inwebber-api/CHANGELOG.md. - Stage the changed files by name, commit, tag
api/vX.Y.Z,git push origin main --tags. - Gitea CI (
build-api.yml) builds and pushes the image on the tag; Watchtower deploys it. - Verify:
curl http://192.168.86.149:8086/health.
CLI releases (cli/vX.Y.Z) currently only log a TODO in CI — there is no build/publish step
to trigger yet.
Known issues (carried over, unverified beyond what's stated)
- Model hallucination: the Explore agent's model can hallucinate file contents instead of
using actual tool results, per
webber-api/AGENTS.md— a mitigation (stronger model or response validation) was suggested there but not confirmed implemented. - Ollama
content: nullworkaround:src/ollama/provider.py(confirmed present, loaded at startup per thesys.modulescheck above) sanitizescontent: nulltocontent: ""for assistant messages with tool calls, working around an Ollama API limitation.