All commands run from assistant/.
uv sync --dev # install deps
uv run pytest tests/ -v # run tests (make test)
uv run pytest tests/test_agent.py -k test_name # one test
uv run ruff check src/ tests/ # lint (make lint)360 tests, ~87% coverage, and they run in about two seconds — there is no
reason not to run them constantly. Pytest uses asyncio_mode = "auto", so
async tests need no decorator, and HTTP is mocked with respx. No test should
ever make a real network call.
No container needed. Paths default to the working directory, so:
cd assistant
cp -r ../vault.template/. ./vault # gitignored
cp config.example.toml config.toml # add your bot token and user id
uv run assistant auth # one-time device flow (make auth)
uv run assistant run # (make run)./vault, ./state, ./config.toml and any local Compose file are all
gitignored, so a development setup can never be committed by accident.
You need your own Telegram bot for this — don't develop against the one you
actually use. /newbot in @BotFather is free and
takes a minute.
To develop against a container instead, copy the Compose file from
deployment.md and swap image: for build: . — deployment
manifests are documented rather than shipped, so there is no dev Compose file
in the repo.
assistant/
src/assistant/
__main__.py ← entry point; wires everything together
agent.py ← the tool-calling loop
copilot.py ← auth + chat completions (streaming)
telegram_bot.py ← long polling, handlers, allowlist
tools.py ← vault file tools (path-jailed)
schedule.py ← APScheduler over system/schedule.md
skills.py ← skill discovery and loading
web.py ← quarantined research sub-agent
fanout.py ← concurrent read-only worker sub-agents
extract.py ← PDF/image content extraction
transcribe.py ← voice notes via GitHub Models
usage.py ← token accounting
lifecycle.py ← signals and graceful shutdown
config.py ← TOML + env config
prompts/*.md ← capability prompts, assembled per run
skills/*.md ← shipped skills
tests/ ← one file per module
vault.template/ ← seed users copy; never a real vault
docs/ ← this
AGENTS.md at the repo root is the detailed architecture reference — what each module does and, more usefully, why the non-obvious parts are the way they are (why streaming is mandatory, why the shutdown ordering matters, why the system prompt must stay byte-stable). Read it before changing anything structural.
- Tools return strings, including errors. The agent loop converts
exceptions into
[tool error: ...]strings for the model rather than crashing. - Missing files return a sentinel,
[file not found: ...], instead of raising. Callers check the prefix. - Every content tool caps its output and says so in the returned string. An uncapped one can displace the whole conversation.
- The assembled system prompt must stay byte-stable across runs, or the provider's prompt cache stops working. Anything that changes goes at the end — that is why the skills menu is appended last.
- Deployment specifics stay out of Python. Container paths, Compose
settings and
maketargets belong in the Dockerfile and the docs.
Full house style is in CONTRIBUTING.md.
Three places, all in agent.py unless the tool has its own module:
- A schema in
Agent._all_tools()— plain OpenAI function-calling JSON. - A dispatch branch in
Agent._dispatch_tool(), returning a string. - A prompt section in
src/assistant/prompts/, if the model needs to be told when to reach for it. Sections are gated on the feature being wired, so an optional tool's prompt only loads when it is enabled.
.github/workflows/ci.yml runs ruff and pytest on every pull request and push
to main. .github/workflows/publish-image.yml builds and pushes the
multi-arch image to GHCR on pushes to main (latest) and on v*.*.* tags.
Action versions are pinned to commit SHAs. Keep it that way — and verify a SHA actually belongs to the tag you think it does before pinning it.