Day 2 — Worktree D — Master Config
You are a Sonnet subagent in an isolated worktree. You have NO conversation history. This document is your full brief.
Run model + setup
- Model: Sonnet (you).
- Subagent type: general-purpose.
- Working dir: temp worktree.
git fetch origingit checkout -b feat/verticals-platform-day2-D-master-config origin/feat/verticals-platform-day1-foundation
- DO NOT push to
mainorfeat/verticals-platform-day1-foundation. PR back tofeat/verticals-platform-day1-foundation. DO NOT merge own PR.
Read first (in order)
C:/dev/knowledge/specs/day2-verticals-platform/00-MAIN-ORCHESTRATOR.mdC:/Users/johnm/.claude/plans/steady-munching-penguin.md— sections "Layer 1 — Master Config" and "Phase 4 (Master Config)" verification.C:/dev/knowledge/voice-clients/README.md— cascade rules + sync workflow.C:/dev/knowledge/voice-clients/_schema.yaml— JSON Schema for per-tenant YAML.C:/dev/knowledge/voice-clients/_defaults/funeral.yamlandchurch.yaml— vertical defaults.C:/dev/src/lib/tenant-config.ts— TS schema (mirrors Pydantic).C:/dev/src/app/founder/[token]/page.tsx(andoutreach-enginepage) — existing founder page patterns.C:/dev/src/app/api/admin/provision-number/route.ts— existing founder-token-gated API pattern.C:/dev/scripts/directory — existing TS scripts pattern.
Scope
D.1 — scripts/sync-voice-clients.ts
Idempotent YAML ↔ DB sync. Default: push (YAML → DB). Modes:
pnpm sync-voice-clients # push all YAML to tenant_voice_agents
pnpm sync-voice-clients --vertical=funeral # push only one vertical
pnpm sync-voice-clients --check # validate, no writes (CI gate)
pnpm sync-voice-clients --dry-run # show diff, no writes
pnpm sync-voice-clients --tenant=<slug> --pull # pull DB row → overwrite YAML
Implementation outline:
// scripts/sync-voice-clients.ts
import { promises as fs } from 'fs';
import path from 'path';
import yaml from 'js-yaml';
import Ajv from 'ajv';
import { createClient } from '@supabase/supabase-js';
import type { TenantConfig } from '../src/lib/tenant-config';
const KNOWLEDGE_VOICE_CLIENTS = path.resolve(__dirname, '../../knowledge/voice-clients');
async function main() {
const args = parseArgs();
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
const schema = yaml.load(await fs.readFile(path.join(KNOWLEDGE_VOICE_CLIENTS, '_schema.yaml'), 'utf8'));
const ajv = new Ajv({ allErrors: true, strict: false });
const validate = ajv.compile(schema as any);
// Load defaults per vertical
const defaults = {
church: yaml.load(await fs.readFile(path.join(KNOWLEDGE_VOICE_CLIENTS, '_defaults/church.yaml'), 'utf8')),
funeral: yaml.load(await fs.readFile(path.join(KNOWLEDGE_VOICE_CLIENTS, '_defaults/funeral.yaml'), 'utf8')),
};
// Discover per-tenant YAML files
const verticals = ['church', 'funeral'];
for (const v of verticals) {
if (args.vertical && args.vertical !== v) continue;
const dir = path.join(KNOWLEDGE_VOICE_CLIENTS, v);
if (!await dirExists(dir)) continue;
const files = (await fs.readdir(dir)).filter(f => f.endsWith('.yaml'));
for (const f of files) {
const tenantSlug = f.replace('.yaml', '');
if (args.tenant && args.tenant !== tenantSlug) continue;
// Cascade: defaults[v] < per-tenant
const perTenant = yaml.load(await fs.readFile(path.join(dir, f), 'utf8'));
const merged = mergeDeep(defaults[v], perTenant);
// Validate
if (!validate(merged)) {
console.error(`[ERROR] ${v}/${f} fails schema:`, validate.errors);
process.exitCode = 1;
continue;
}
if (args.check) {
console.log(`[OK] ${v}/${f} valid`);
continue;
}
if (args.pull) {
// Pull DB → YAML
const row = await fetchTenantRow(supabase, merged.tenant_id, v);
const yamlOut = dbRowToYaml(row, defaults[v]);
await fs.writeFile(path.join(dir, f), yaml.dump(yamlOut, { sortKeys: false }));
console.log(`[PULLED] ${v}/${f} ← DB`);
continue;
}
// Push YAML → DB
const dbPayload = yamlToDbRow(merged);
if (args.dryRun) {
const existing = await fetchTenantRow(supabase, merged.tenant_id, v);
printDiff(existing, dbPayload);
} else {
await upsertTenantRow(supabase, dbPayload, v);
console.log(`[PUSHED] ${v}/${f} → DB`);
}
}
}
}
yamlToDbRow maps the YAML camel/snake fields to the actual tenant_voice_agents columns. mergeDeep does the cascade.
D.2 — package.json script entry
{
"scripts": {
"sync-voice-clients": "tsx scripts/sync-voice-clients.ts"
}
}
Add js-yaml, ajv, tsx, @types/js-yaml to devDependencies if not present.
D.3 — /founder/[token]/voice-clients/page.tsx (list view)
Token-gated (FOUNDER_TOKEN). Lists all tenants from tenant_voice_agents (joined with premium_churches for church, premium_funeral_homes for funeral). Columns: slug, vertical, plan, status, transfer_enabled (badge), notification_phone, last_call_at.
Each row links to /founder/[token]/voice-clients/[id]/edit.
Sort by status (active first), then vertical, then slug.
D.4 — /founder/[token]/voice-clients/[id]/edit/page.tsx (edit view)
Form for one tenant. Fields grouped:
- Identity (read-only): tenant_id, slug, vertical, display_name
- Notification: notification_email, notification_phone, escalation_contact_name (per OperationalHandoff)
- Operational handoff: transfer_target_number, transfer_enabled (toggle), transfer_business_hours_only (toggle), business_hours (JSON editor), response_time_promise_text, transfer_ring_seconds (10–60), bridge_intro_template (textarea)
- Safety: crisis_contact_phone, crisis_resources_locale (US/CA/UK/AU)
- Voice: voice_id (Cartesia UUID or 'random'), welcome_greeting
- Inbound numbers: read-only list (managed by
/api/admin/provision-number) - Vertical-specific: open JSON textarea (low-touch field for now)
Each field shows "inherited from default" badge if empty (matches default). On save, POSTs to /api/founder/voice-clients/[id] (CRUD route).
Buttons:
- Save — writes to DB
- Export to YAML — downloads the per-tenant YAML for git commit (POST to
/api/founder/voice-clients/[id]/export-yamlreturns the YAML string) - Reset to defaults — clears overrides (sets DB columns back to NULL for inherited fields)
D.5 — /api/founder/voice-clients/route.ts and /api/founder/voice-clients/[id]/route.ts
CRUD + export-yaml endpoints. FOUNDER_TOKEN gated. Uses Supabase service role.
// GET /api/founder/voice-clients → list
// GET /api/founder/voice-clients/[id] → one
// PATCH /api/founder/voice-clients/[id] → update
// POST /api/founder/voice-clients/[id]/export-yaml → download YAML
Validation: use the same JSON schema (_schema.yaml) on the PATCH payload. Reject with 400 + error details if fails.
D.6 — Seed YAML for existing customers
Create one YAML file per existing tenant by READING from production via Supabase MCP and writing the equivalent YAML. Tenants to seed:
knowledge/voice-clients/church/medhanialem.yaml(church_id from premium_churches)knowledge/voice-clients/church/melvindale-cog.yaml(Melvindale Church of God)knowledge/voice-clients/church/hope-community.yaml(Hope Community)knowledge/voice-clients/church/zewdei.yaml(Zewdei — same as Medhanialem? verify)knowledge/voice-clients/funeral/walker-mortuary-daughenb-e6c1.yaml(founder's demo funeral home)
For each: query tenant_voice_agents (or church_voice_agents view) + the identity table, write the cascaded YAML using _defaults/<vertical>.yaml as the floor. Only persist fields that DIVERGE from the default (keep YAML lean). Add a header comment with the source row's last updated_at.
DO NOT seed with secrets (cal_api_key, pco_secret, etc.). Those stay in DB only — YAML carries null placeholders. The UI lets the founder enter them but they don't round-trip to git.
D.7 — Drift gate (CI integration prep)
pnpm sync-voice-clients --check returns non-zero exit code if any per-tenant YAML fails schema validation OR if any field in the cascaded YAML diverges from the corresponding DB row.
Add a .github/workflows/voice-clients-drift.yml that runs --check on every PR touching knowledge/voice-clients/** or src/lib/tenant-config.ts. Block merge on failure.
(Don't worry about deploying the workflow file — just author it. CI is owned at the repo level.)
D.8 — Documentation
Update knowledge/voice-clients/README.md with the actual pnpm sync-voice-clients flags + the UI URL /founder/[token]/voice-clients. Add a short "How to provision a new tenant" walkthrough that combines existing /api/admin/provision-number (number + trunk) with the new Master Config UI (per-tenant overrides).
Files you will modify
package.json # add script + deps
knowledge/voice-clients/README.md # update with actual workflow
.github/workflows/voice-clients-drift.yml # new CI workflow (or update if exists)
Files you will create
scripts/sync-voice-clients.ts
src/app/founder/[token]/voice-clients/page.tsx
src/app/founder/[token]/voice-clients/[id]/edit/page.tsx
src/app/api/founder/voice-clients/route.ts
src/app/api/founder/voice-clients/[id]/route.ts
src/app/api/founder/voice-clients/[id]/export-yaml/route.ts
knowledge/voice-clients/church/medhanialem.yaml
knowledge/voice-clients/church/melvindale-cog.yaml
knowledge/voice-clients/church/hope-community.yaml
knowledge/voice-clients/church/zewdei.yaml # if distinct from Medhanialem; otherwise skip
knowledge/voice-clients/funeral/walker-mortuary-daughenb-e6c1.yaml
Files NOT to touch
voice-agent-livekit/**— A and B own.src/app/admin/[token]/**— C owns.src/middleware.ts— C owns.src/lib/verticals/**— C creates.src/lib/tenant-config.ts— Day 1 stable.src/lib/voice-provision.ts— existing pattern, don't refactor.knowledge/voice-clients/_schema.yamland_defaults/— Day 1 final, don't change.- ANY production database migration.
- ANY LiveKit/Telnyx provisioning command.
Verification (run before opening PR)
# Lint + type-check
pnpm lint
npx tsc --noEmit --skipLibCheck scripts/sync-voice-clients.ts src/app/founder/[token]/voice-clients/page.tsx
# Schema check on every authored YAML
pnpm sync-voice-clients --check
# Dry-run against production (read-only mode)
pnpm sync-voice-clients --dry-run --vertical=church
pnpm sync-voice-clients --dry-run --vertical=funeral
# Build
pnpm build
The dry-run should report ZERO diffs after seeding (because seed YAMLs are derived FROM production data). If it shows diffs, your seed has bugs — fix, re-seed, re-run.
When done
git add -A
git commit -m "feat(verticals): Day 2 Worktree D — Master Config UI + sync script + seed YAML"
git push -u origin feat/verticals-platform-day2-D-master-config
gh pr create \
--base feat/verticals-platform-day1-foundation \
--title "feat(verticals): Day 2 D — Master Config — /founder/[token]/voice-clients UI + pnpm sync-voice-clients + seed YAML" \
--body "$(cat <<'EOF'
## Summary
Day 2 Worktree D. Master Config form factor: YAML in git + UI tweaks layered + Export to YAML round-trip. Builds the founder UI at `/founder/[token]/voice-clients`, the sync script at `scripts/sync-voice-clients.ts` (YAML ↔ DB), seed YAML for existing tenants, and CI drift gate workflow.
See `knowledge/specs/day2-verticals-platform/04-WORKTREE-D-master-config.md` for the full spec.
## Test plan
- [ ] `pnpm sync-voice-clients --check` passes (all seed YAMLs valid)
- [ ] `pnpm sync-voice-clients --dry-run` shows zero diffs (seed derived from prod)
- [ ] `pnpm build` succeeds
- [ ] Manual: `/founder/[token]/voice-clients` lists existing tenants
- [ ] Manual: edit a tenant via UI → save → "Export to YAML" → diff appears in working tree
🤖 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 (pnpm sync-voice-clients --check, --dry-run, build all green)
- Any deviations + why
- Anything for Day 3 integration
Hard rules
- NEVER apply migrations.
- NEVER write to production tables that aren't
tenant_voice_agents(e.g., do NOT touch premium_churches, churches, etc.). - NEVER push to
mainorfeat/verticals-platform-day1-foundation. - NEVER include secrets (cal_api_key, pco_secret, supabase keys) in YAML files. They stay in DB only.
You're cleared to start.