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>
13 KiB
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'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 — 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
- Fresh Environment Test —
cp .env.example .env, setDATABASE_URLand eitherCASDOOR_*+OWNER_NAME(SSO) orCASDOOR_ENABLED=falsewithHOST=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_errorin main.py). It must never boot into a state where it silently can't place calls — that's whyuse_mock_sipis opt-in and an unconfigured trunk fails startup. - 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.
- Observable & Debuggable —
/healthis honest: it reportsdegradedwith 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 typedEventBus; new call-lifecycle facts become typed events, notprints. - 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. - 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 blocks911/9911/112(and their E.164 forms, whitespace/dashes stripped) atgateway.make_call, before the concurrency check and before any SIP action. Every dialling path — RESTmake_call, MCPmake_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 bypassesmake_call's guard. If you add a new outbound path, it calls the guard first. See the safety rule. -
The concurrency cap is real spend control.
max_concurrent_calls(default 4) caps simultaneous outbound calls ingateway.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
ED2dispatcher 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_eventfunnel) 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_bearerin auth.py turns either into aUser, andis_owner/get_current_ownergate 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_configrefuses 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. -
/healthtells the truth.healthyrequires 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 viacall_persistence. MCP/REST history and summaries read from the DB throughsession_scope(). Don't confuse the two — a call that ended is gone fromactive_callsand only exists in the DB.
Config — pydantic-settings, nested, singleton
Config is Settings in 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 and the
README's config table. See the config rule.
.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) 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_callraisesToolErroron 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.
What's real vs. stubbed (don't mistake one for the other)
- PJSUA2 media pipeline runs in stub mode unless the
pjsua2bindings 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 —/healthreportsengine: mockand refuseshealthy.
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=jsonin core/logging_config.py, applied at import and again inlifespanbecause uvicorn installs its own handlers (propagate=False) after importing the app. The access log is included, withstatus_codeas a number so Loki can range-filter it. Text remains the default; the Docker image sets json.- No
/metricsendpoint and no Prometheus. Unlike the metrics-bearing estate services, there's no exposition endpoint here yet. - No health-probe access-log filter. Every
/healthpoll 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 tomain. The compose stack requires SSO enabled (published port ⇒ 0.0.0.0 ⇒ dev-owner mode refused). No systemd unit in-repo. - A committed
.DS_Storeis not present (good), but do checkgit statusstays clean of OS cruft;.gitignorealready lists it.
Working here
- Run:
uvicorn main:app --host 0.0.0.0 --port 8000(orpython main.py). The CLI--portwins oversettings.portin 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,py312target). - Dashboard:
cd dashboard && npm install && npm run build→ served at/whendashboard/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 viainit_db. Schema changes are migrations, never ad-hocCREATE.
Path-scoped rules in .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.