Started as the SIP_ENGINE row flagged in the last commit. Cross-checking the tables against config.py mechanically (rather than by eye) turned up more, including two entries that were actively wrong. Corrections: - GATEWAY_RTP_PORT_MIN/MAX and GATEWAY_HOST are documented in configuration.md but do not exist — no code reads them and they are absent from .env.example. Setting them today does nothing. Replaced with the real GATEWAY_SIP_ fields (host/port/domain). - GATEWAY_SIP_PORT was documented as 5080 in two places; the code and .env.example both say 5060. - DATABASE_URL was documented with a SQLite default. There is none, and startup exits if it is unset. Additions — every env var the models accept is now documented somewhere (verified bidirectionally: nothing in the models undocumented, nothing documented that the models reject): - Server section: HOST, PORT, DEBUG, LOG_LEVEL, LOG_FORMAT - Safety section: MAX_CONCURRENT_CALLS, USE_MOCK_SIP, SIP_ENGINE - Receptionist section (configuration.md had none, though seven vars exist) Structural staleness, from the PR #8 media-plane work: - core/pjsua_engine.py was absent from the component list and file tree; so were dial_plan.py (the emergency guard) and sip_engine.py. - architecture.md's banner still read "media plane in transition". The engine landed; it is now two selectable engines with the audio consequence stated. - Tech Stack described "single-process async architecture" — the simplification CLAUDE.md explicitly calls out. Now points at the threading model, since there are three execution contexts. - The Asterisk lab shipped in PR #8 with its own README but nothing linked to it. Linked from the test section and both doc indexes. - CLAUDE.md's "no structured JSON logging" gap is closed; test count was 146 across 16 files, now 189 across 19. The other listed gaps (no /metrics, no rate limiting, no health-probe log filter) were re-verified and still hold. Deliberately not hardcoding a test count in the README — that is the same staleness this commit is clearing up. All internal links and anchors verified to resolve; 189 tests pass; lint unchanged at its 216 baseline. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
243 lines
13 KiB
Markdown
243 lines
13 KiB
Markdown
# 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` (189 tests across 19 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.**~~ Done: `LOG_FORMAT=json` in
|
|
[core/logging_config.py](core/logging_config.py), applied at import and again
|
|
in `lifespan` because uvicorn installs its own handlers (`propagate=False`)
|
|
after importing the app. The access log is included, with `status_code` as a
|
|
number so Loki can range-filter it. Text remains the default; the Docker image
|
|
sets json.
|
|
- **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.
|