Skip to main content

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 origin
    git checkout -b feat/verticals-platform-day2-D-master-config origin/feat/verticals-platform-day1-foundation
  • DO NOT push to main or feat/verticals-platform-day1-foundation. PR back to feat/verticals-platform-day1-foundation. DO NOT merge own PR.

Read first (in order)

  1. C:/dev/knowledge/specs/day2-verticals-platform/00-MAIN-ORCHESTRATOR.md
  2. C:/Users/johnm/.claude/plans/steady-munching-penguin.md — sections "Layer 1 — Master Config" and "Phase 4 (Master Config)" verification.
  3. C:/dev/knowledge/voice-clients/README.md — cascade rules + sync workflow.
  4. C:/dev/knowledge/voice-clients/_schema.yaml — JSON Schema for per-tenant YAML.
  5. C:/dev/knowledge/voice-clients/_defaults/funeral.yaml and church.yaml — vertical defaults.
  6. C:/dev/src/lib/tenant-config.ts — TS schema (mirrors Pydantic).
  7. C:/dev/src/app/founder/[token]/page.tsx (and outreach-engine page) — existing founder page patterns.
  8. C:/dev/src/app/api/admin/provision-number/route.ts — existing founder-token-gated API pattern.
  9. 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-yaml returns 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.yaml and _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 main or feat/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.