# CLAUDE.md β€” Hold Slayer πŸ”₯🐾 Red Panda Standards for the Hold Slayer telephony gateway. This is an **AI-powered PSTN gateway**: it places real phone calls, navigates IVR menus, waits on hold, and rings a human's desk phone when a live person answers. It also answers inbound calls with an AI receptionist and smart routing. **It dials real numbers on a real SIP trunk and may incur telephony charges, and an AI agent drives it.** Treat every change through that lens: a bug here isn't a 500, it's an unwanted phone call β€” or a *refused emergency call that should never have been attempted in the first place*. Read this file before touching call placement, auth, the SIP thread boundary, or the emergency guard. Lead with a paw print in this repo. --- ## The shape of this thing (read once, then it's obvious) One FastAPI process exposes four surfaces over the same port: **REST** (`/api/v1/*`), **WebSocket** (`/ws/*`), an **MCP server** (streamable HTTP at `/mcp/`), and the built **SvelteKit dashboard** at `/`. All four are **owner-only**, gated by Casdoor SSO (browser JWT) or an owner-minted PAT through one shared resolver. Under them sits a **composition root** in [main.py](main.py)'s `lifespan`: the gateway and every service are constructed and wired there, then hung on `app.state`. Nothing constructs its own dependencies β€” if you need a new service, build it in the lifespan and pass it in. Below the services is the part that makes this app unusual: a **`SippyB2BUAEngine` that runs the SIP/PJSUA2 event loop on its own OS thread**, not the asyncio loop. This is the highest-leverage invariant in the codebase. See [the concurrency rule](.claude/rules/concurrency-threads.md) β€” the README's "single-process async" line is a simplification; there are two execution contexts and exactly one funnel between them. ``` REST / WS / MCP / Dashboard (asyncio, FastAPI) β”‚ composition root (lifespan) β†’ services β†’ gateway β”‚ SippyB2BUAEngine ──┬── asyncio side (legs, bridges, event bus) └── Sippy thread (UA objects, ED2 dispatcher) ↑ crossed only via _post_from_ed / _run_on_sippy ``` --- ## Red Panda Approvalβ„’ β€” what it means here 1. **Fresh Environment Test** β€” `cp .env.example .env`, set `DATABASE_URL` and either `CASDOOR_*` + `OWNER_NAME` (SSO) or `CASDOOR_ENABLED=false` with `HOST=127.0.0.1` (dev-owner, loopback only), `pip install -e ".[dev]"`, `uvicorn main:app`. It must boot to a clear log banner or **exit with a human-readable reason** (see `_check_startup_config` / `_handle_db_error` in [main.py](main.py)). It must *never* boot into a state where it silently can't place calls β€” that's why `use_mock_sip` is opt-in and an unconfigured trunk fails startup. 2. **Elegant Simplicity** β€” the composition root wires; services do one job; the thread boundary has exactly one funnel each way. Don't add a second path across the thread line, a second auth mechanism, or a service that reaches into another service's internals. 3. **Observable & Debuggable** β€” `/health` is *honest*: it reports `degraded` with the reason (mock engine, unregistered trunk, DB down, STT/TTS unreachable) rather than a green light that lies. Keep it honest. Events flow through the typed `EventBus`; new call-lifecycle facts become typed events, not `print`s. 4. **Consistent Patterns** β€” config via pydantic-settings sub-configs; MCP tools return formatted strings; REST returns Pydantic models; DB access via `session_scope()`. Match the neighbours. 5. **Actually Works** β€” `pytest tests/ -v` (146 tests across 16 files). A change to call placement, routing, the classifier, or auth needs a test. The suite runs against SQLite (`aiosqlite`) and the mock SIP engine β€” no trunk, no Postgres required to test. --- ## Invariants that must not be weakened These are safety- and correctness-critical. Loosening one is never a casual refactor β€” it needs an explicit rationale and, where it deviates from a stated standard, a note (there is no `docs/EXCEPTIONS.md` yet; if you start accumulating documented deviations, create one rather than letting them go unrecorded). - **The emergency-number guard is absolute.** `is_emergency_number()` in [core/dial_plan.py](core/dial_plan.py) blocks `911`/`9911`/`112` (and their E.164 forms, whitespace/dashes stripped) at `gateway.make_call`, **before** the concurrency check and before any SIP action. Every dialling path β€” REST `make_call`, MCP `make_call`, receptionist call-back, transfer to an external number β€” must pass through a guard that refuses these. An AI agent must never place an emergency call, and API calls carry no E911 location. **Do not add a dial path that bypasses `make_call`'s guard.** If you add a new outbound path, it calls the guard first. See [the safety rule](.claude/rules/call-safety.md). - **The concurrency cap is real spend control.** `max_concurrent_calls` (default 4) caps simultaneous outbound calls in `gateway.make_call`. It's checked after the emergency guard, before creating the call. Don't remove it or move it below call creation. - **The Sippy thread boundary is crossed only through the two funnels.** Sippy UA objects and the `ED2` dispatcher live on the Sippy thread; legs, bridges, and the event bus live on the asyncio loop. Cross **threadβ†’loop** only via `_post_from_ed` (β†’ `run_coroutine_threadsafe` β†’ the single `_on_engine_event` funnel) and **loopβ†’thread** only via `_run_on_sippy` (β†’ `ED2.callFromThread`). Never touch a Sippy UA object from the loop; never mutate loop-owned state from a Sippy handler. This is the whole reason the app is thread-safe. - **Auth is Casdoor SSO + owner-minted PATs, owner-only, one resolver.** The browser signs in via Casdoor (short-lived JWT); MCP/CLI clients use owner-minted PATs (`hs_pat_…`). `resolve_bearer` in [auth.py](auth.py) turns either into a `User`, and `is_owner`/`get_current_owner` gate every surface to the single operator (`OWNER_NAME`) β€” non-owners get 403. Dev mode (`CASDOOR_ENABLED=false`) resolves the dev owner and is **only** permitted on a loopback bind; `_check_startup_config` refuses SSO-off-loopback and SSO-on-with-missing-config. Keep those refusals β€” a silent dev-owner-open-on-0.0.0.0 is the failure mode they exist to prevent. See [the auth rule](.claude/rules/auth-surfaces.md). - **`/health` tells the truth.** `healthy` requires a real (non-mock) engine, a registered trunk, and a reachable DB. Don't relax it to make a probe go green; a gateway that can't place calls is not healthy, and the dashboard/operator needs to see that. - **The database is the source of truth for history; live state is in memory.** Active calls live in the `CallManager`; completed calls + transcripts are persisted on hangup via `call_persistence`. MCP/REST history and summaries read from the DB through `session_scope()`. Don't confuse the two β€” a call that ended is gone from `active_calls` and only exists in the DB. --- ## Config β€” pydantic-settings, nested, singleton Config is `Settings` in [config.py](config.py): a root `BaseSettings` with **nested sub-config models** (`SIPTrunkSettings`, `LLMSettings`, `TTSSettings`, `ReceptionistSettings`, …), each with its own `env_prefix` (`SIP_TRUNK_`, `LLM_`, `TTS_`, `RECEPTIONIST_`, …). Read config through `get_settings()` (a cached singleton) β€” don't call `os.environ.get` for Hold Slayer settings, and don't construct `Settings()` yourself outside that accessor. > **Estate note (not a defect):** unlike the `KERNOS_`/`ARGOS_`/`NIKE_`-style > single-prefix services, Hold Slayer has **no one umbrella prefix** β€” root vars > (`DATABASE_URL`, `HOST`, `MAX_CONCURRENT_CALLS`, `OWNER_NAME`) are unprefixed > and each subsystem carries its own (`CASDOOR_` for the SSO connection). That's a > deliberate readability choice for > a config with this many subsystems; keep new vars consistent with the > sub-config they belong to, and if you add a new subsystem, give it its own > sub-config + prefix rather than piling flat vars onto the root. Secrets (`casdoor.client_secret`, `sip_trunk.password`, `llm.api_key`, `tts.api_key`) are `SecretStr` β€” keep them so, and read via `.get_secret_value()` only at the point of use. Every new var also needs a row in [.env.example](.env.example) and the README's config table. See [the config rule](.claude/rules/config-startup.md). **`.env` hygiene:** `.env` is gitignored and holds real secrets β€” never commit it, never treat the local `.env` as a template. Only `.env.example` (placeholders) is committed. --- ## MCP tools β€” formatted strings, and the error convention to know MCP tools ([mcp_server/server.py](mcp_server/server.py)) return **plain human-readable strings** (not JSON, not Pydantic models β€” that's the REST layer's job). Resources (`gateway://status`, `gateway://call-flows`, `gateway://active-calls`) return JSON strings. There's a **deliberate but uneven error convention** worth understanding before you add a tool: - `make_call` raises `ToolError` on a bad request (emergency number, bad mode, cap hit) β€” a hard failure the assistant should treat as an error. - Most read/lookup tools **return** an error *string* (`"Call … not found."`, `"Error looking up …: {e}"`) instead of raising β€” a soft "here's what happened" the assistant reads as content. When you add a tool: raise `ToolError` for "you asked for something invalid or unsafe"; return a plain string for "I looked and here's the (possibly empty) answer." The gateway is resolved lazily per call via `require_gateway()` (it raises `ToolError` while the gateway is still starting) β€” keep that, because the MCP app is mounted before the lifespan builds the gateway. See [the MCP rule](.claude/rules/mcp-tools.md). --- ## What's real vs. stubbed (don't mistake one for the other) - **PJSUA2 media pipeline runs in stub mode** unless the `pjsua2` bindings are built from pjproject (not pip-installable). Signaling works; audio routing/recording is a no-op stub without them. Code that assumes real audio must degrade honestly, and `/health`/engine-mode must reflect stub vs real. - **The mock SIP engine (`MockSIPEngine`) must be asked for** (`USE_MOCK_SIP=true`). It exists for tests and local dev. Production must not silently run on it β€” `/health` reports `engine: mock` and refuses `healthy`. --- ## Known gaps (flag, don't fold β€” these are not your task unless asked) Surfaced honestly so you don't rediscover them as surprises. The README's Phase 4/5/6 checklists track most of these; **don't fold fixes into unrelated work** β€” raise them. - **No structured JSON logging.** Logging is plain `logging.basicConfig` in [main.py](main.py); there's no `LOG_FORMAT`/JSON path (README Phase 4 has this unchecked). If Heluca observability wants JSON logs shipped to a collector, that's a deliberate piece of work, not a drive-by. - **No `/metrics` endpoint and no Prometheus.** Unlike the metrics-bearing estate services, there's no exposition endpoint here yet. - **No health-probe access-log filter.** Every `/health` poll hits the access log. Other estate services suppress probe noise; this one doesn't. - **No rate limiting** on API endpoints (README Phase 4, unchecked). - **Docker: single-image `Dockerfile` + `docker-compose.yaml`** (app + `postgres:17`) ship in-repo; the Gitea CI (`cve-scan-docker-build.yml`) builds the image on push to `main`. The compose stack requires SSO enabled (published port β‡’ 0.0.0.0 β‡’ dev-owner mode refused). No systemd unit in-repo. - **A committed `.DS_Store` is *not* present** (good), but do check `git status` stays clean of OS cruft; `.gitignore` already lists it. --- ## Working here - **Run:** `uvicorn main:app --host 0.0.0.0 --port 8000` (or `python main.py`). The CLI `--port` wins over `settings.port` in the startup banner logic β€” a real gotcha the banner code already accounts for; don't "fix" it into disagreement. - **Test:** `pytest tests/ -v`. Fast, no external services (SQLite + mock SIP). - **Lint:** `ruff check .` (line length 100, `py312` target). - **Dashboard:** `cd dashboard && npm install && npm run build` β†’ served at `/` when `dashboard/build/` exists. The API/WS/health/MCP routes are registered **before** the `"/"` static mount because a root mount matches everything β€” keep that ordering. - **DB migrations:** Alembic (`db/migrations/`), upgrade-on-boot via `init_db`. Schema changes are migrations, never ad-hoc `CREATE`. Path-scoped rules in [.claude/rules/](.claude/rules/) load automatically when you edit the files they cover. They carry the fine-grained "don't break this" detail; this file is the map.