Files
hold-slayer/CLAUDE.md
Robert Helewka e2051f7486
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 44s
CVE Scan & Docker Build / build-and-push (push) Successful in 1m53s
docs: document SIP_ENGINE and correct config tables against the models
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>
2026-07-30 17:57:31 -04:00

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

  1. Fresh Environment Testcp .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). 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 prints.
  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 Workspytest 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 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.

  • 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 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.

  • /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: 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_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.


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, 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/ load automatically when you edit the files they cover. They carry the fine-grained "don't break this" detail; this file is the map.