Updated: 2026-06-16
Runtime prerequisites (Docker, Ollama, etc.) see Deployment Guide #1.
Additionally for development:
| Software | Version | Purpose |
|---|---|---|
| Python | >= 3.14 | Runtime + Tests |
| uv | latest | Package manager (brew install uv or docs.astral.sh/uv) |
| Tailwind CSS CLI | v3.4.17 | CSS Build (standalone binary, no Node.js) |
| pre-commit | latest | Git hook framework (uv tool install pre-commit) |
git clone https://github.com/gerfru/NilesAI.git Niles
cd Nilesuv sync --frozen --extra devcp .env.example .envAll environment variables, Ollama setup, and service configuration (Google OAuth, WhatsApp, Vikunja, etc.) are documented in the Deployment Guide:
- Quick Start -- Required variables
- Environment Reference -- Complete variable table
- Ollama -- LLM setup
- Vikunja -- Task setup
Complete settings table with defaults: Niles-Core-Spec.md #6.1.
Templates use Tailwind CSS utility classes. The generated style.css is served by FastAPI as a static file.
# macOS ARM64:
curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.17/tailwindcss-macos-arm64
chmod +x tailwindcss-macos-arm64
mv tailwindcss-macos-arm64 tailwindcss# One-time build:
./tailwindcss --minify -i src/niles/static/css/input.css -o src/niles/static/css/style.css
# Watch mode (on template changes):
./tailwindcss --watch -i src/niles/static/css/input.css -o src/niles/static/css/style.cssThe Dockerfile automatically downloads Tailwind CLI and builds CSS (python urllib.request.urlretrieve). When changing templates or input.css, the Docker image must be rebuilt -- or style.css built locally and provided via volume mount.
Configuration: tailwind.config.js in the project root defines content paths and dark mode (class).
./scripts/dev.shStarts uvicorn with auto-reload on http://127.0.0.1:8000. Requires PostgreSQL and Evolution API to be running externally (e.g., via Docker).
./scripts/start.shStarts all containers (PostgreSQL, Evolution API, Niles Core, Caddy). For live code reload during development, use ./scripts/dev.sh (Option A) instead -- the Docker setup runs without --reload and requires a rebuild for code changes.
HTTPS: Caddy terminates TLS with self-signed certificates. For local testing, use --insecure with curl:
curl -k https://localhost/health
curl -k -X POST https://localhost/chat \
-H "X-API-Key: <KEY>" \
-H "Content-Type: application/json" \
-d '{"message": "Test"}'Web UI: Open https://localhost/ui/login in the browser.
Alternatively via the Docker-internal port (without TLS): docker exec niles_core curl http://localhost:8000/health
Postgres Debugging: The Postgres port is not exposed by default. To access the database directly (e.g., via psql), set in .env:
POSTGRES_HOST_PORT=5432Then: psql -h 127.0.0.1 -U evolution -d evolution_db
./scripts/status.sh./scripts/stop.sh./scripts/test.shtest.sh runs the fast unit-test suite only. It deselects the slow/infra
markers (-m "not integration and not e2e and not llm_judge") and uses a plain
python3.14 -m venv .venv with pip install -e .[dev] (not uv).
Note:
uv run pytest tests/ -vis not equivalent — without the marker deselection it would also collect theintegrationande2etests (which require a live Docker Compose stack / Ollama and otherwise fail or skip). To reproduce the fast suite directly:pytest tests/ -v -m "not integration and not e2e and not llm_judge".
Defined in [tool.pytest.ini_options].markers (pyproject.toml):
| Marker | Meaning | Deselect with |
|---|---|---|
integration |
Requires Docker Compose infrastructure | -m "not integration" |
e2e |
End-to-end agent pipeline tests | -m "not e2e" |
llm_judge |
Requires Ollama + Claude API key, slow | -m "not llm_judge" |
llm_eval |
Behavioral quality evals against live Ollama | -m "not llm_eval" |
| Script | Scope | Requirements |
|---|---|---|
./scripts/test.sh |
Fast unit suite (deselects integration/e2e/llm_judge) | None (plain .venv) |
./scripts/test-integration.sh |
tests/integration/ (-m integration) |
Running Docker Compose stack, .env, Ollama |
./scripts/test-e2e.sh [pipeline|judge|all] |
tests/e2e/ |
PostgreSQL; judge mode also needs ANTHROPIC_API_KEY + Ollama |
Configured in [tool.coverage] (pyproject.toml):
branch = true— branch coverage is measured, not just line coverage.fail_under = 77— the test run fails if total (branch-inclusive) coverage drops below 77% (raised from 70 after the W16/W17 route/agent-loop test additions).- Source:
src/niles;tests/*andalembic/*are omitted.
Non-exhaustive — representative layout. The flat
tests/*.pyfiles are the unit suite; the subdirectories hold the infra-dependent / behavioral tests.
tests/
├── conftest.py # Shared fixtures (environment variables)
├── helpers.py # Shared test helpers
├── test_core.py # NilesAgent, tool-call pipeline, text-tool-call fallback
├── test_web.py # Web UI, Google OAuth, sessions, CSRF
├── test_web_routes.py # Web route handlers
├── test_security.py # API auth, rate limiting
├── test_migrations.py # Alembic migration chain validation
├── ... # ~60 further flat unit-test modules
├── integration/ # @pytest.mark.integration — live Docker/Ollama
│ ├── conftest.py
│ ├── test_calendar_integration.py
│ ├── test_tasks_integration.py
│ └── ... # contacts, mcp, memory, notion, signal, stores, whatsapp
├── e2e/ # @pytest.mark.e2e — pipeline / Claude-as-Judge
│ ├── conftest.py
│ ├── fake_llm.py # FakeLLM driver
│ ├── judge.py # Claude-as-Judge harness
│ ├── test_http_e2e.py
│ ├── test_tool_pipeline.py
│ └── test_llm_judge.py
└── evals/ # @pytest.mark.llm_eval — behavioral quality
├── eval_gate.py
├── golden_dataset.json
├── baseline.json
└── test_llm_evals.py
- Framework: pytest with
pytest-asyncio asyncio_mode = "auto"inpyproject.toml(no@pytest.mark.asyncioneeded)- External dependencies (PostgreSQL, LLM) are mocked with
unittest.mock.AsyncMock conftest.pysets required environment variables viamonkeypatch- Test files:
tests/test_<module>.py - Test classes:
class Test<Class>: - Web UI tests use signed session tokens via
itsdangerous.URLSafeTimedSerializerwith a separate_TEST_SESSION_SECRET
docker compose -f docker/docker-compose.yml --env-file .env build niles_core# All containers
docker compose -f docker/docker-compose.yml logs -f
# Niles Core only
docker compose -f docker/docker-compose.yml logs -f niles_coreAll code changes require rebuilding the container:
docker compose -f docker/docker-compose.yml --env-file .env up -d --build niles_coreFor faster iteration, use ./scripts/dev.sh (local uvicorn with --reload).
Schema changes are managed by Alembic with raw SQL migrations (op.execute()). No SQLAlchemy ORM -- Niles uses asyncpg directly.
- Alembic runs as a standalone CLI tool with a sync connection (via
psycopg2) - Niles Core runs async (via
asyncpg) -- the two never share a connection - Schema version is tracked in the
alembic_versiontable - Store
initialize()methods contain only business logic, noCREATE TABLE
# 1. Create migration file
DATABASE_URL="postgresql://evolution:password@localhost:5432/evolution_db" \
alembic revision -m "add_email_integration"
# 2. Edit the generated file in alembic/versions/
# - Write upgrade() with raw SQL via op.execute()
# - Write downgrade() with reverse SQL
# 3. Test locally
DATABASE_URL="..." alembic upgrade head
DATABASE_URL="..." alembic downgrade -1
DATABASE_URL="..." alembic upgrade head
# 4. Commit migration fileAll migrations use op.execute() with raw SQL. No SQLAlchemy Table objects.
"""Short description of the change."""
from alembic import op
revision = "003"
down_revision = "002"
def upgrade():
op.execute("""
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone TEXT
""")
def downgrade():
op.execute("""
ALTER TABLE users DROP COLUMN IF EXISTS phone
""")# Roll back one migration
DATABASE_URL="..." alembic downgrade -1
# Show current version
DATABASE_URL="..." alembic current
# Show migration history
DATABASE_URL="..." alembic history| File | Description |
|---|---|
001_baseline.py |
Initial schema (all 11 tables + indexes) |
002_migrate_contact_phones.py |
Data migration: legacy phone columns → contact_phones |
003_add_notion_rag.py |
pgvector extension, notion_pages + notion_embeddings tables |
004_user_google_tokens.py |
Per-user Google OAuth tokens for gws MCP server |
005_notion_hierarchical_chunks.py |
Add chunk_level column for 2-level (summary + detail) chunking |
006_notion_metadata_columns.py |
Add page_title + heading_context for keyword boost scoring |
007_user_soft_delete.py |
Soft-delete columns (is_active, deactivated_at) on users |
008_calendar_user_id.py |
Per-user calendar sources (user_id column) |
009_vikunja_password_synced.py |
Vikunja password sync tracking columns |
010_drop_google_calendar.py |
Remove legacy Google Calendar source type |
011_contacts_per_user.py |
Per-user contacts scoping |
012_memory_user_id.py |
Per-user memory scoping (user_id column on memory table) |
- Add tool definition to the
TOOLSlist insrc/niles/agent/tool_defs.py(OpenAI function calling format) - Create a handler module in
src/niles/agent/tools/<name>.pywith a@register_tool("tool_name")decorated async function - Add the side-effect import in
src/niles/agent/tools/__init__.py - Add tests in
tests/test_core.py(or new test file)
Handler signature: async def handle_<name>(args: dict, chat_id: str, ctx: ToolContext) -> dict
Example:
from . import ToolContext, register_tool
@register_tool("my_tool")
async def handle_my_tool(args: dict, chat_id: str, ctx: ToolContext) -> dict:
result = await ctx.some_action.do_thing(args["param"])
return {"status": "ok", "data": result}- Create file
src/niles/actions/<name>.py - Implement class with async methods
- Instantiate in
startup.pyhelpers and pass to agent viamain.pylifespan - Write tests with mocked external calls
- Create file
src/niles/sources/<name>.py - FastAPI router with webhook endpoint
- Create event dict and pass to
agent.process_event() - Include router in
main.py:app.include_router(router)
Alternative: WebSocket listener pattern. Signal uses a background asyncio.Task that maintains a persistent WebSocket connection to signal-cli-rest-api instead of receiving webhook callbacks. The listener task is started during lifespan() and cancelled on shutdown. Use this pattern when the external service provides a push-based WebSocket stream rather than calling back to a webhook endpoint.
- Code: English (variables, functions, comments, docstrings)
- Agent prompts: German (soul.md, tool descriptions)
- Documentation: English
- Web UI labels: German (target language of the end user)
- All I/O operations are
async - PostgreSQL via
asyncpg(connection pool) - HTTP via
httpx.AsyncClient - LLM via
openai.AsyncOpenAI
- Webhook handlers: Catch and log exceptions, always return HTTP 200
- Web UI: Catch agent errors, display error message in chat
- LLM errors: Error message to user, no exception propagation
- Tool call errors:
{"error": "..."}as tool result back to LLM - Startup:
ValidationErroron missing required variables ->sys.exit(1)
Smaller local LLMs (e.g., llama3.1:8b via Ollama) sometimes don't use the function calling API but output the tool call as JSON text:
{"name": "create_task", "parameters": {"title": "Shopping", "due_date": "2026-02-24"}}NilesAgent._try_parse_text_tool_call() detects such responses and executes the tool call anyway. In streaming mode, JSON-like responses are buffered (not immediately streamed to the user) so that no raw JSON appears in the chat bubble.
Note: LLM parameters are sometimes delivered as strings instead of the correct type (e.g., "priority": "0" instead of "priority": 0). Actions must handle such types robustly (int() with fallback).
logging.getLogger(__name__)in every module (stdlib loggers are routed through structlog)- Structured JSON output to stdout via
structlog(src/niles/logging_config.py) - Level configurable via
LOG_LEVELenvironment variable - Request tracing:
request_idis automatically bound to all log entries viastructlog.contextvars - Noisy loggers (
httpx,httpcore) are set to WARNING
Niles must never delete user data. This principle is enforced on three levels:
- No delete tools: The TOOLS list contains no delete operations.
complete_taskonly marks as done. - MCP destructive tool blocking: MCP tools with destructive name prefixes (
delete_,remove_,drop_, etc.) are automatically blocked during tool discovery (src/niles/mcp/client.py,_DESTRUCTIVE_PREFIXES). Limitation: Prefix-based only -- tools likebulk_removeordata_wipe_allare not detected. For stricter control: use per-server allowlists inmcp_servers.yaml. - soul.md Rule 7: The LLM is instructed to refer users to the respective app for deletion requests.
When adding new tools or integrations: do not expose delete_* methods to the LLM. Deletions only via web UI with explicit user interaction.
Niles uses APScheduler for automatic background jobs. All jobs are registered during lifespan() (via startup.py helpers):
| Job ID | Schedule | Condition | Module |
|---|---|---|---|
carddav_daily_sync |
Daily 03:00 | carddav_url configured |
sync/carddav.py |
calendar_sources_sync |
Daily 03:20 | Calendar sources exist | sync/manager.py |
briefing_daily |
Mon-Fri, configurable | feature_briefing_daily=true |
jobs/briefing.py |
briefing_weekly |
Mon, configurable | feature_briefing_weekly=true |
jobs/briefing.py |
notion_sync |
Every N minutes (configurable) | feature_notion=true |
sync/notion.py + sync/notion_embeddings.py |
Briefing pattern: The briefing jobs (jobs/briefing.py) receive app.state as argument. At runtime (not at registration), the connected WhatsApp number is determined from the whatsapp_sessions table. If no session is connected, the briefing is skipped (no error).
- Deployment Guide -- Setup, configuration, backup, troubleshooting
- Technical Specification -- Architecture, components, configuration
- API Reference -- Endpoints, payloads, examples