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 origingit checkout -b feat/verticals-platform-day2-A-live-transfer origin/feat/verticals-platform-day1-foundation
- DO NOT branch from
main. Branch fromfeat/verticals-platform-day1-foundation(Day 1 foundation has TenantConfig, core/transfer.py, etc.). - DO NOT push to
mainor tofeat/verticals-platform-day1-foundationdirectly. Open a PR back tofeat/verticals-platform-day1-foundationwhen done. - DO NOT merge your own PR.
Read first (in order)
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".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.C:/dev/knowledge/decisions/2026-04-28-funeralwiseai-live-transfer-plan.md— the 8 sub-questions and the killer-demo mechanic.C:/dev/voice-agent-livekit/core/transfer.py— the file you'll wire in. Already vertical-agnostic, already readsOUTBOUND_TRUNK_IDenv var (currently logs a warning if empty, you tighten that to a hard assertion in production).C:/dev/voice-agent-livekit/core/tenant_config.py— Pydantic schema.TenantConfig.operational_handoffcarries the runtime config.C:/dev/voice-agent-livekit/verticals/church/agents.pylines 353–429 — the existingtransfer_to_directorfunction tool on the church CoordinatorAgent. Currently imports fromverticals/church/transfer_tool.py(the old non-shared version). You switch this import.C:/dev/voice-agent-livekit/verticals/funeral/prompts.py— funeral prompt builder. Currently knows aboutrequest_callback. You addtransfer_to_directorwhen-to-fire instructions.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_directorwhen:- 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
- At-need death just occurred AND the family is on the line right now AND we have a configured
- Fire
request_callback(with urgency='pastoral_emergency' for at-need) when:transfer_enabled=falseOR notransfer_target_numberconfigured- Outside
business_hours(iftransfer_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/tokenwith{ 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_overrideinto LiveKit room metadata (separate field fromprospect_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.pyremoved; church Coordinator usescore/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_IDhard-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_callbacktool 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_Xa3Bp9aixRFPor 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
mainor tofeat/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.