Skip to main content

Day 2 — Worktree A — Live Transfer + 3 v1 Additions + Chatbot

You are a Sonnet subagent in an isolated worktree. You have NO conversation history. This document is your full brief. Read it through, plan, then execute.

Run model + setup

  • Model: Sonnet (you).
  • Subagent type: voice-agent-engineer (touches voice-agent code + life-safety paths).
  • Working dir: a temp worktree that the platform created for you. From there:
    git fetch origin
    git checkout -b feat/verticals-platform-day2-A-live-transfer origin/feat/verticals-platform-day1-foundation
  • DO NOT branch from main. Branch from feat/verticals-platform-day1-foundation (Day 1 foundation has TenantConfig, core/transfer.py, etc.).
  • DO NOT push to main or to feat/verticals-platform-day1-foundation directly. Open a PR back to feat/verticals-platform-day1-foundation when done.
  • DO NOT merge your own PR.

Read first (in order)

  1. C:/dev/knowledge/specs/day2-verticals-platform/00-MAIN-ORCHESTRATOR.md — the orchestrator handoff. Skim sections "The mission, in one paragraph", "Day 1 state", "Hard rules".
  2. C:/Users/johnm/.claude/plans/steady-munching-penguin.md — the master plan. Read sections "Day 1 Task #1 reconciled" (so you understand the existing transfer plumbing) and "Day 2 — Parallel build" Worktree A row.
  3. C:/dev/knowledge/decisions/2026-04-28-funeralwiseai-live-transfer-plan.md — the 8 sub-questions and the killer-demo mechanic.
  4. C:/dev/voice-agent-livekit/core/transfer.py — the file you'll wire in. Already vertical-agnostic, already reads OUTBOUND_TRUNK_ID env var (currently logs a warning if empty, you tighten that to a hard assertion in production).
  5. C:/dev/voice-agent-livekit/core/tenant_config.py — Pydantic schema. TenantConfig.operational_handoff carries the runtime config.
  6. C:/dev/voice-agent-livekit/verticals/church/agents.py lines 353–429 — the existing transfer_to_director function tool on the church CoordinatorAgent. Currently imports from verticals/church/transfer_tool.py (the old non-shared version). You switch this import.
  7. C:/dev/voice-agent-livekit/verticals/funeral/prompts.py — funeral prompt builder. Currently knows about request_callback. You add transfer_to_director when-to-fire instructions.
  8. C:/dev/tmp/research-competitor-live-transfer.md — the v1 additions came from this report. Read sections "Best-in-class bridge intro patterns" and "Real-world failure modes".

Scope

Eight workstreams. Each is small but together they ship a polished, demo-grade live transfer plus a chatbot operational-handoff parity.

A.1 — Switch the import to the shared core/transfer.py

In voice-agent-livekit/verticals/church/agents.py (around line 391):

# BEFORE
from verticals.church.transfer_tool import _execute_attended_transfer

# AFTER
from core.transfer import execute_attended_transfer as _execute_attended_transfer

Then update the call at line 412 to match the new keyword-only signature in core/transfer.py:

result = await _execute_attended_transfer(
session=self.session,
chat_ctx=self.chat_ctx,
target_number=transfer_number,
caller_name=caller_name,
brief_context=brief_context,
tenant_name=church.get("name", ""),
director_name=church.get("escalation_contact_name", ""),
bridge_intro_template=church.get("bridge_intro_template"), # NEW field
ring_seconds=church.get("transfer_ring_seconds", 25), # NEW field
)

Then delete voice-agent-livekit/verticals/church/transfer_tool.py (it's the old version, now unused).

Update voice-agent-livekit/verticals/church/integrations/supabase_church.py:278 so the church_data dict carries the new fields:

"transfer_target_number": data.get("transfer_target_number") or None,
"transfer_enabled": bool(data.get("transfer_enabled")),
"transfer_business_hours_only": bool(data.get("transfer_business_hours_only")),
"business_hours": data.get("business_hours") or None,
"response_time_promise_text": data.get("response_time_promise_text") or "shortly",
"transfer_ring_seconds": data.get("transfer_ring_seconds") or 25,
"bridge_intro_template": data.get("bridge_intro_template") or None,
"crisis_contact_phone": data.get("crisis_contact_phone") or None,
"crisis_resources_locale": data.get("crisis_resources_locale") or "US",

A.2 — Funeral data loader picks up the same fields

In voice-agent-livekit/verticals/funeral/data.py:_fetch_tenant_voice_agent() extend the SELECT to include the new columns (transfer_target_number, transfer_enabled, business_hours, response_time_promise_text, transfer_ring_seconds, bridge_intro_template, crisis_contact_phone, crisis_resources_locale). And in load_funeral_home_data() add them to the assembled home dict.

A.3 — Funeral prompt: when-to-fire transfer_to_director

In voice-agent-livekit/verticals/funeral/prompts.py, find the _build_human_escalation() block (added by PR #246). Add a parallel section explaining when to fire transfer_to_director vs request_callback:

  • Fire transfer_to_director when:
    • At-need death just occurred AND the family is on the line right now AND we have a configured transfer_target_number
    • Caller explicitly demands to speak with a director immediately
    • Urgent legal/cert question requiring real-time human judgment
  • Fire request_callback (with urgency='pastoral_emergency' for at-need) when:
    • transfer_enabled=false OR no transfer_target_number configured
    • Outside business_hours (if transfer_business_hours_only=true)
    • Caller prefers an asynchronous reach-out

Add the per-tenant response_time_promise_text to the fallback message verbatim, e.g. "I'll have a director call you {response_time_promise_text}."

CRITICAL: Forbid the agent from saying "I'm going to connect you right now" without firing the tool. Add explicit prompt language: "If you intend to live-transfer, you MUST first call transfer_to_director. NEVER say you are connecting unless you have invoked the tool."

A.4 — v1 ADDITION: Auto-greet on pickup

In voice-agent-livekit/core/transfer.py, after the pickup-detected branch (currently around line 305 where we do pickup_detected = True; break), add a 1-second delay then have the agent say "Hello?" through session.say() BEFORE delivering the bridge intro. This mimics Retell's pattern of detecting voicemail by speaking immediately on pickup; if it's a real human they reply, if it's voicemail the audio falls into silence.

if pickup_detected:
# v1 addition (2026-04-28 from competitor research): auto-greet to detect voicemail
await asyncio.sleep(1.0)
try:
await session.say("Hello?", allow_interruptions=False)
except Exception:
# Best-effort; don't fail the bridge if greeting fails
pass
# Then continue with bridge intro

Document the pattern + cite Retell's approach in a comment.

A.5 — v1 ADDITION: User-facing wait message during bridge

While the agent is dialing the director (between create_sip_participant and pickup detection), the caller hears silence. Add a single user-facing wait message (one short sentence, configurable) so the caller knows what's happening.

In voice-agent-livekit/core/transfer.py, immediately after create_sip_participant succeeds (around line 270, after the logger.info "outbound participant created"), say a wait message to the room. The default template (override-able via TenantConfig):

"Stay with me — I'm getting {director_name} on the line. About 30 seconds."

Token interpolation same as bridge_intro_template. Add a new TenantConfig field wait_message_template to OperationalHandoff in voice-agent-livekit/core/tenant_config.py and src/lib/tenant-config.ts (string | null, null = default). Pull the corresponding column add into a new migration file migrations/2026-04-28_tenant_voice_agents_wait_message.sql (idempotent ADD COLUMN IF NOT EXISTS).

DO NOT apply the migration yourself — leave it as an authored file in the migrations dir. Day 3 integration applies it via the founder gate.

A.6 — v1 ADDITION: Hard crisis-flag block contract test

Create voice-agent-livekit/tests/test_transfer_crisis_gate.py. Use pytest-asyncio.

Test cases (each must FAIL the transfer with reason='crisis_gate' and success=False):

@pytest.mark.parametrize("phrase", [
"I want to end my life",
"I'm thinking about suicide",
"I'm going to kill myself",
"Just tired of living",
"no one would miss me",
"I'm a burden",
"988",
"He's hitting me",
"She's hitting me",
"domestic violence",
"DV hotline",
"I want to harm someone", # threat — also blocked
"I'm going to hurt them",
])
async def test_crisis_phrase_blocks_transfer(phrase):
chat_ctx = make_chat_ctx_with_user_turn(phrase)
result = await execute_attended_transfer(
session=mock_session(),
chat_ctx=chat_ctx,
target_number="+14169154112",
caller_name="Test",
brief_context="Test",
)
assert result["success"] is False
assert result["reason"] == "crisis_gate"
assert result["timeout"] is False

Plus an INVERSE test (a non-crisis chat must NOT trigger the gate):

async def test_normal_phrase_passes_gate():
chat_ctx = make_chat_ctx_with_user_turn("My grandmother just passed at hospice")
# Pickup mocked as never (so we expect timeout fallback, NOT crisis_gate)
result = await execute_attended_transfer(...)
assert result["reason"] != "crisis_gate"

Add an entry in .github/CODEOWNERS (if it exists) or create the file:

voice-agent-livekit/tests/test_transfer_crisis_gate.py @JohnMoelker
voice-agent-livekit/core/transfer.py @JohnMoelker
voice-agent-livekit/core/escalation.py @JohnMoelker

If CODEOWNERS already exists, just add the new lines.

A.7 — v1 TIGHTENING: OUTBOUND_TRUNK_ID hard assertion

In voice-agent-livekit/core/transfer.py, the current _resolve_outbound_trunk_id() warns if empty but proceeds with an empty sip_trunk_id. Per the LiveKit research report (tmp/research-livekit-outbound.md section "Telnyx outbound trunk gotchas"), sip_trunk_id="" does NOT default — it returns not_found. Tighten to a hard assertion in production:

def _resolve_outbound_trunk_id() -> str:
trunk_id = os.getenv("OUTBOUND_TRUNK_ID", "").strip()
if not trunk_id:
env = os.getenv("LK_ENV") or os.getenv("LIVEKIT_ENV") or "production"
if env == "production":
raise RuntimeError(
"OUTBOUND_TRUNK_ID is empty in production. "
"LiveKit's CreateSIPParticipantRequest requires an explicit "
"trunk ID — empty does NOT default. "
"Set OUTBOUND_TRUNK_ID via `lk agent update-secrets`."
)
logger.warning("[TRANSFER] OUTBOUND_TRUNK_ID empty in dev — calls will fail.")
return trunk_id

Add unit test voice-agent-livekit/tests/test_transfer_env.py:

def test_missing_outbound_trunk_id_fails_in_production(monkeypatch):
monkeypatch.delenv("OUTBOUND_TRUNK_ID", raising=False)
monkeypatch.setenv("LK_ENV", "production")
with pytest.raises(RuntimeError, match="OUTBOUND_TRUNK_ID is empty"):
from core.transfer import _resolve_outbound_trunk_id
_resolve_outbound_trunk_id()

A.8 — v1 SAFETY: X-Telnyx-Username header on outbound trunk

Per the LiveKit research, force credential auth on Telnyx side via the X-Telnyx-Username header on the outbound trunk. Update the LiveKit outbound trunk via the LiveKit API or the lk CLI.

Document the command to run in knowledge/runbooks/voice-provisioning.md (you'll edit that runbook in this worktree, no need to actually execute the command — Day 3 integration runs it as part of the founder gate, since modifying a live SIP trunk needs founder presence):

# Set X-Telnyx-Username header on outbound trunk to force credential auth on Telnyx side.
# Run from cwa-wt-sermonwise/voice-agent-livekit/ (any clean checkout).
USERNAME=$(grep TELNYX_OUTBOUND_USERNAME tmp/telnyx-outbound-creds.env | cut -d'=' -f2)
C:/dev/lk.exe sip outbound update --project cwa-voice --id ST_X3n9jxR55VrB \
--header "X-Telnyx-Username=$USERNAME"
# Verify:
C:/dev/lk.exe sip outbound list --project cwa-voice

If lk sip outbound update doesn't support --header, use the LiveKit REST API directly. Document the exact curl command in the runbook.

A.9 — Demo-flip UI on /s/[slug]

In src/app/s/[slug]/page.tsx, add a small client component below the existing chatbot widget:

<DirectorTransferDemo slug={slug} />

The component (src/components/cold-outreach/DirectorTransferDemo.tsx):

  • Input: phone number (E.164, validate client-side)
  • Button: "Try the live director transfer with your own phone number"
  • Click → POST to /api/livekit/token with { slug, demoOverrides: { directorPhone: "+1xxxxxxxxxx" } }
  • On 200 response with token → connect via LiveKit JS SDK (already in the project) and start a browser-based call
  • Show stage indicators: "Connecting…" → "AI is on the line" → "Director is being dialed…" → "Bridged"

Server-side guards (in /api/livekit/token extension):

  • E.164 validation
  • Rate limit by IP: max 3 demo dials/day (use Supabase RLS-bypassing query or in-memory cache; Phase 2 adds proper rate limiting)
  • Token mint stamps demo_director_phone_override into LiveKit room metadata (separate field from prospect_slug)
  • Session TTL: ≤30 min

In voice-agent-livekit/main.py:_build_funeral_prospect_path(), read demo_director_phone_override from room metadata and stamp it into church_data["transfer_target_number"] (overriding the per-tenant column for this session only). Same for church_data["transfer_enabled"] = True for the demo session only.

A.10 — Chatbot operational handoff (parity with voice)

In src/app/api/chatbot/stream/route.ts, add a new tool to the streamText tool registry:

{
name: 'request_callback',
description: 'Submit a callback request when the user asks to speak with a director, pastor, or human. ALWAYS confirm name + phone with the user before calling.',
inputSchema: z.object({
caller_name: z.string(),
caller_phone: z.string().regex(/^\+?[1-9]\d{7,14}$/),
reason: z.string(),
urgency: z.enum(['normal', 'urgent', 'pastoral_emergency']).default('urgent'),
preferred_time: z.string().optional(),
}),
execute: async ({ caller_name, caller_phone, reason, urgency, preferred_time }) => {
// Insert into voice_callback_requests with same shape as voice path
const { error } = await supabaseAdmin
.from('voice_callback_requests')
.insert({
church_id: churchId, // from session
caller_name,
caller_phone,
reason,
urgency,
preferred_time,
source: 'chat', // set source = 'chat' (column already exists per memory)
});
if (error) {
return { success: false, message: 'I had trouble submitting that. Please try again.' };
}
// Send SMS + email via existing Resend + Telnyx helpers (mirror voice path)
await sendUrgentCallbackEmail(churchId, { caller_name, caller_phone, reason, urgency });
if (urgency !== 'normal') {
await sendUrgentCallbackSms(churchId, { caller_name, caller_phone, reason, urgency });
}
return { success: true, message: `I've made sure ${directorLabel} gets your message. They'll reach out ${responseTimePromiseText}.` };
},
}

The request_callback tool is fed directorLabel and responseTimePromiseText from the loaded TenantConfig. Use src/lib/tenant-config.ts:adaptTenantVoiceAgentRow + directorLabel helper.

CRITICAL: when crisis patterns hit (CRISIS_PATTERNS regex around route.ts:289), do NOT offer request_callback — defer to the existing 988 + crisis_events write path (which is Worktree B's scope; coordinate via the contract test in Worktree B).

Add system-prompt instructions: "When a user asks to speak with a human, director, pastor — confirm their name and phone, then call request_callback. Never claim you've contacted the team if you haven't called the tool."

Files you will modify

voice-agent-livekit/verticals/church/agents.py # switch import
voice-agent-livekit/verticals/church/integrations/supabase_church.py # extend dict
voice-agent-livekit/verticals/funeral/data.py # extend dict
voice-agent-livekit/verticals/funeral/prompts.py # add transfer_to_director instructions
voice-agent-livekit/core/transfer.py # auto-greet, wait message, env assertion
voice-agent-livekit/core/tenant_config.py # add wait_message_template
src/lib/tenant-config.ts # add waitMessageTemplate
src/app/api/chatbot/stream/route.ts # add request_callback tool
src/app/api/livekit/token/route.ts # accept demoOverrides.directorPhone
src/app/s/[slug]/page.tsx # render DirectorTransferDemo
voice-agent-livekit/main.py # read demo_director_phone_override
knowledge/runbooks/voice-provisioning.md # X-Telnyx-Username header runbook

Files you will create

voice-agent-livekit/tests/test_transfer_crisis_gate.py
voice-agent-livekit/tests/test_transfer_env.py
src/components/cold-outreach/DirectorTransferDemo.tsx
migrations/2026-04-28_tenant_voice_agents_wait_message.sql # do NOT apply
.github/CODEOWNERS # if not exists; otherwise edit

Files you will DELETE

voice-agent-livekit/verticals/church/transfer_tool.py # replaced by core/transfer.py

Verify no other module imports it before deletion (grep -r "from verticals.church.transfer_tool" voice-agent-livekit/).

Files NOT to touch

  • voice-agent-livekit/safety.py — Worktree B owns this.
  • voice-agent-livekit/moderation.py — Worktree B owns this.
  • voice-agent-livekit/core/escalation.py — Worktree B creates this.
  • src/app/admin/[token]/** — Worktree C owns this.
  • src/middleware.ts — Worktree C owns this.
  • src/lib/verticals/** — Worktree C creates this.
  • src/app/founder/[token]/voice-clients/** — Worktree D creates this.
  • src/app/api/founder/voice-clients/** — Worktree D creates this.
  • scripts/sync-voice-clients.ts — Worktree D creates this.
  • knowledge/voice-clients/{church,funeral}/<slug>.yaml — Worktree D seeds these.
  • ANY production database migration — applied during Day 3 integration.
  • ANY LiveKit secret update or trunk modification — done during Day 3 integration with founder.

Verification (run before opening PR)

# Python syntax + tests
cd voice-agent-livekit
python -m py_compile core/transfer.py core/tenant_config.py verticals/church/agents.py verticals/funeral/prompts.py verticals/funeral/data.py main.py
python -m pytest tests/test_transfer_crisis_gate.py -v
python -m pytest tests/test_transfer_env.py -v

# TypeScript type-check
cd ..
npx tsc --noEmit --skipLibCheck src/app/api/chatbot/stream/route.ts src/lib/tenant-config.ts src/components/cold-outreach/DirectorTransferDemo.tsx

# Migration file syntax check (Postgres-flavored — visually inspect; do not apply)
cat migrations/2026-04-28_tenant_voice_agents_wait_message.sql

# Confirm old transfer_tool.py is removed and nothing imports it
grep -r "from verticals.church.transfer_tool" voice-agent-livekit/ || echo "OK: no imports of old module"

When done

git add -A
git status # confirm only your scoped files changed
git commit -m "feat(verticals): Day 2 Worktree A — live transfer + 3 v1 additions + chatbot operational handoff"
git push -u origin feat/verticals-platform-day2-A-live-transfer
gh pr create \
--base feat/verticals-platform-day1-foundation \
--title "feat(verticals): Day 2 A — live transfer + auto-greet/wait/crisis-test + chatbot request_callback" \
--body "$(cat <<'EOF'
## Summary
Day 2 Worktree A. Wires `core/transfer.py` into church Coordinator, populates funeral data, adds funeral prompt instructions for `transfer_to_director`. Plus 3 v1 additions from competitor research: auto-greet on pickup, user-facing wait message during bridge, hard crisis-flag block contract test. Plus tightens `OUTBOUND_TRUNK_ID` to a production assertion. Plus adds `X-Telnyx-Username` header runbook entry. Plus chatbot `request_callback` tool for operational-handoff parity with voice.

See `knowledge/specs/day2-verticals-platform/01-WORKTREE-A-live-transfer.md` for the full spec.

## Test plan
- [ ] `python -m pytest voice-agent-livekit/tests/test_transfer_crisis_gate.py -v` — all 13 phrase variants block transfer
- [ ] `python -m pytest voice-agent-livekit/tests/test_transfer_env.py -v` — empty OUTBOUND_TRUNK_ID raises in production
- [ ] `npx tsc --noEmit src/app/api/chatbot/stream/route.ts` — chatbot request_callback tool type-checks
- [ ] Manual: deploy preview → place call to demo line → ask for director → bridge to founder's phone → conversation works (Day 3 integration)
- [ ] Manual: browser call on `/s/<funeral-slug>` → enter phone → "ask for director" → own phone rings (Day 3 integration)
- [ ] Manual: chatbot session → "Can I speak with the pastor?" → request_callback fires → SMS arrives (Day 3 integration)

## Migration applied
- Authored: `migrations/2026-04-28_tenant_voice_agents_wait_message.sql` (NOT applied — Day 3)

🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"

Then in your final message back to the orchestrator (≤300 words):

  • PR URL
  • Commit SHA(s)
  • Verification status (each test green/red)
  • Any deviations from this spec + why
  • Anything you found that needs to be in the Day 3 integration checklist

What "done" looks like

  • Old transfer_tool.py removed; church Coordinator uses core/transfer.py.
  • Funeral path populated with new fields; funeral prompt teaches transfer_to_director.
  • Three v1 additions live in code with passing tests.
  • OUTBOUND_TRUNK_ID hard-asserts in production.
  • X-Telnyx-Username runbook entry written.
  • Demo-flip UI renders on /s/[slug] (NOT yet end-to-end tested — Day 3).
  • Chatbot request_callback tool wired with same shape as voice.
  • One PR open against feat/verticals-platform-day1-foundation. NOT MERGED.

Hard rules (do not violate)

  • NEVER touch ST_Xa3Bp9aixRFP or its dispatch rules.
  • NEVER deploy the voice agent yourself (lk agent deploy).
  • NEVER apply database migrations yourself.
  • NEVER use lk agent update-secrets --overwrite.
  • NEVER push to main or to feat/verticals-platform-day1-foundation.
  • NEVER write to production Supabase tables (read-only is fine for verifying schema).
  • If you hit something that needs a production action, STOP and report back to the orchestrator.

You're cleared to start.