[object Object]

← back to Butlr

polish: (1) 44-business preset directory across 13 categories with IVR notes per business — Chase/AmEx/Cedars-Sinai/UCLA/Anthem/Delta/Comcast/IRS/etc; (2) 4-step onboarding wizard (category → business picker → goal + account → callback + consent) replacing the single mega-form, with progress bar + hidden-input state carry; (3) Twilio Voice worker — dry-run mode by default, advances calls through queued → dialing → on_hold → connected → done over ~50s simulated; live mode scaffolded behind TWILIO_DRY_RUN=0 + creds; 5s tick interval.

c889dbbf4553335c106a56e534a02418f9b8b433 · 2026-05-12 11:55:27 -0700 · SteveStudio2

Files touched

Diff

commit c889dbbf4553335c106a56e534a02418f9b8b433
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 11:55:27 2026 -0700

    polish: (1) 44-business preset directory across 13 categories with IVR notes per business — Chase/AmEx/Cedars-Sinai/UCLA/Anthem/Delta/Comcast/IRS/etc; (2) 4-step onboarding wizard (category → business picker → goal + account → callback + consent) replacing the single mega-form, with progress bar + hidden-input state carry; (3) Twilio Voice worker — dry-run mode by default, advances calls through queued → dialing → on_hold → connected → done over ~50s simulated; live mode scaffolded behind TWILIO_DRY_RUN=0 + creds; 5s tick interval.
---
 data/businesses.json               | 310 +++++++++++++++++++++++++++++++++++++
 lib/data.js                        |  17 ++
 lib/twilio.js                      | 131 ++++++++++++++++
 public/css/site.css                |  56 +++++++
 public/js/wizard.js                |  28 ++++
 routes/public.js                   | 146 ++++++++++++++---
 server.js                          |  10 ++
 views/partials/wizard-progress.ejs |   9 ++
 views/partials/wizard-state.ejs    |  12 ++
 views/public/wizard-1-category.ejs |  32 ++++
 views/public/wizard-2-business.ejs |  64 ++++++++
 views/public/wizard-3-details.ejs  |  71 +++++++++
 views/public/wizard-4-callback.ejs |  93 +++++++++++
 13 files changed, 957 insertions(+), 22 deletions(-)

diff --git a/data/businesses.json b/data/businesses.json
new file mode 100644
index 0000000..7b14be1
--- /dev/null
+++ b/data/businesses.json
@@ -0,0 +1,310 @@
+[
+  {
+    "slug": "chase-credit-card",
+    "name": "Chase Credit Card Customer Service",
+    "phone": "1-800-432-3117",
+    "category_hints": ["credit-card-increase", "credit-card-dispute"],
+    "ivr_notes": "After greeting, say 'representative' twice or press 0. Hold time avg 8-15 min weekdays."
+  },
+  {
+    "slug": "amex-customer-service",
+    "name": "American Express Customer Service",
+    "phone": "1-800-528-4800",
+    "category_hints": ["credit-card-increase", "credit-card-dispute"],
+    "ivr_notes": "Say 'representative'. AmEx routes Platinum/Centurion to dedicated lines via account verification."
+  },
+  {
+    "slug": "capital-one",
+    "name": "Capital One Credit Card",
+    "phone": "1-800-227-4825",
+    "category_hints": ["credit-card-increase", "credit-card-dispute"],
+    "ivr_notes": "Press 2 for credit card, then say 'representative'."
+  },
+  {
+    "slug": "citi-cards",
+    "name": "Citi Cards Customer Service",
+    "phone": "1-800-950-5114",
+    "category_hints": ["credit-card-increase", "credit-card-dispute"],
+    "ivr_notes": "Press # then 0 to skip account-number prompt and go to queue."
+  },
+  {
+    "slug": "discover",
+    "name": "Discover Card Customer Service",
+    "phone": "1-800-347-2683",
+    "category_hints": ["credit-card-increase", "credit-card-dispute"],
+    "ivr_notes": "Say 'agent' at any point. Hold time typically short (5-10 min)."
+  },
+  {
+    "slug": "boa-credit-card",
+    "name": "Bank of America Credit Card",
+    "phone": "1-800-732-9194",
+    "category_hints": ["credit-card-increase", "credit-card-dispute"],
+    "ivr_notes": "Press 0 multiple times to reach agent."
+  },
+  {
+    "slug": "wells-fargo-cc",
+    "name": "Wells Fargo Credit Card",
+    "phone": "1-800-642-4720",
+    "category_hints": ["credit-card-increase", "credit-card-dispute"],
+    "ivr_notes": "Say 'customer service' or press 0."
+  },
+  {
+    "slug": "cedars-sinai-billing",
+    "name": "Cedars-Sinai Patient Billing",
+    "phone": "1-800-233-2771",
+    "category_hints": ["hospital-billing"],
+    "ivr_notes": "Press 1 for billing, then 2 for billing question. Hold time can be 30+ min during peak."
+  },
+  {
+    "slug": "ucla-health-billing",
+    "name": "UCLA Health Billing",
+    "phone": "1-310-301-8800",
+    "category_hints": ["hospital-billing"],
+    "ivr_notes": "Press 2 for patient financial services."
+  },
+  {
+    "slug": "kaiser-billing",
+    "name": "Kaiser Permanente Billing",
+    "phone": "1-800-390-3510",
+    "category_hints": ["hospital-billing", "insurance-claim"],
+    "ivr_notes": "Members say 'member services'. Non-members press 0."
+  },
+  {
+    "slug": "anthem-blue-cross",
+    "name": "Anthem Blue Cross",
+    "phone": "1-855-333-5730",
+    "category_hints": ["insurance-claim"],
+    "ivr_notes": "Have member ID ready. Press 1 for member, then 2 for claims."
+  },
+  {
+    "slug": "united-healthcare",
+    "name": "UnitedHealthcare",
+    "phone": "1-866-633-2446",
+    "category_hints": ["insurance-claim"],
+    "ivr_notes": "Member ID required. Say 'representative' to bypass IVR."
+  },
+  {
+    "slug": "aetna",
+    "name": "Aetna Member Services",
+    "phone": "1-800-872-3862",
+    "category_hints": ["insurance-claim"],
+    "ivr_notes": "Member ID + DOB required. Hold typically 10-20 min."
+  },
+  {
+    "slug": "delta-airlines",
+    "name": "Delta Air Lines Reservations",
+    "phone": "1-800-221-1212",
+    "category_hints": ["airline-change"],
+    "ivr_notes": "SkyMiles members get priority. Say 'agent' at greeting."
+  },
+  {
+    "slug": "american-airlines",
+    "name": "American Airlines Reservations",
+    "phone": "1-800-433-7300",
+    "category_hints": ["airline-change"],
+    "ivr_notes": "AAdvantage Executive Platinum has its own line. Press 0 repeatedly to reach agent."
+  },
+  {
+    "slug": "united-airlines",
+    "name": "United Airlines Reservations",
+    "phone": "1-800-864-8331",
+    "category_hints": ["airline-change"],
+    "ivr_notes": "Premier members can press 2 for direct routing."
+  },
+  {
+    "slug": "southwest-airlines",
+    "name": "Southwest Airlines Customer Service",
+    "phone": "1-800-435-9792",
+    "category_hints": ["airline-change"],
+    "ivr_notes": "Generally short holds. Say 'agent' to skip menu."
+  },
+  {
+    "slug": "spectrum",
+    "name": "Spectrum (Charter) Cable & Internet",
+    "phone": "1-833-267-6094",
+    "category_hints": ["cable-cancel"],
+    "ivr_notes": "Say 'cancel service' to route to retention. Retention will offer aggressive discounts."
+  },
+  {
+    "slug": "comcast-xfinity",
+    "name": "Comcast / Xfinity",
+    "phone": "1-800-934-6489",
+    "category_hints": ["cable-cancel", "utility-dispute"],
+    "ivr_notes": "Say 'cancel'. Routes to retention. Have account number ready."
+  },
+  {
+    "slug": "att-wireless",
+    "name": "AT&T Wireless",
+    "phone": "1-800-331-0500",
+    "category_hints": ["cable-cancel"],
+    "ivr_notes": "Press 0 repeatedly. Say 'cancel' to route to loyalty team."
+  },
+  {
+    "slug": "verizon-wireless",
+    "name": "Verizon Wireless Customer Service",
+    "phone": "1-800-922-0204",
+    "category_hints": ["cable-cancel"],
+    "ivr_notes": "Say 'cancel service' for loyalty team routing. Have last 4 SSN ready."
+  },
+  {
+    "slug": "tmobile",
+    "name": "T-Mobile Customer Care",
+    "phone": "1-800-937-8997",
+    "category_hints": ["cable-cancel"],
+    "ivr_notes": "Press 0 or say 'agent'. T-Mobile's IVR is friendlier than competitors."
+  },
+  {
+    "slug": "ladwp",
+    "name": "Los Angeles Department of Water & Power",
+    "phone": "1-800-342-5397",
+    "category_hints": ["utility-dispute"],
+    "ivr_notes": "Press 3 for billing. Long holds common."
+  },
+  {
+    "slug": "socalgas",
+    "name": "Southern California Gas",
+    "phone": "1-800-427-2200",
+    "category_hints": ["utility-dispute"],
+    "ivr_notes": "Press 2 for billing question, then 1 for residential."
+  },
+  {
+    "slug": "sce-edison",
+    "name": "Southern California Edison",
+    "phone": "1-800-655-4555",
+    "category_hints": ["utility-dispute"],
+    "ivr_notes": "Press 2 for billing, then 1 for residential."
+  },
+  {
+    "slug": "opentable",
+    "name": "OpenTable Customer Support",
+    "phone": "1-800-673-6822",
+    "category_hints": ["restaurant-reservation"],
+    "ivr_notes": "For consumer reservation help. Restaurants direct have higher availability."
+  },
+  {
+    "slug": "irs",
+    "name": "IRS Individual Tax Help",
+    "phone": "1-800-829-1040",
+    "category_hints": ["other"],
+    "ivr_notes": "Famously long holds (1-3 hrs). Press 2 then 1 then 3 then 2 to route fastest. Mon-Fri 7am-7pm local."
+  },
+  {
+    "slug": "social-security",
+    "name": "Social Security Administration",
+    "phone": "1-800-772-1213",
+    "category_hints": ["other"],
+    "ivr_notes": "Long holds. Say 'agent' to bypass automated menu."
+  },
+  {
+    "slug": "medicare",
+    "name": "Medicare",
+    "phone": "1-800-633-4227",
+    "category_hints": ["insurance-claim"],
+    "ivr_notes": "Press 0 to bypass IVR menus. Have Medicare ID ready."
+  },
+  {
+    "slug": "amazon-cs",
+    "name": "Amazon Customer Service",
+    "phone": "1-888-280-4331",
+    "category_hints": ["warranty-claim", "subscription-cancel"],
+    "ivr_notes": "Generally pickup is fast. Have order # ready."
+  },
+  {
+    "slug": "apple-support",
+    "name": "Apple Support",
+    "phone": "1-800-275-2273",
+    "category_hints": ["warranty-claim"],
+    "ivr_notes": "Apple ID required. Hold typically <10 min for Genius routing."
+  },
+  {
+    "slug": "best-buy-geek",
+    "name": "Best Buy Geek Squad",
+    "phone": "1-800-433-5778",
+    "category_hints": ["warranty-claim"],
+    "ivr_notes": "Press 0. Have member ID and serial number ready."
+  },
+  {
+    "slug": "rocket-mortgage",
+    "name": "Rocket Mortgage Servicing",
+    "phone": "1-800-508-0944",
+    "category_hints": ["mortgage-loan"],
+    "ivr_notes": "Press 1 for existing mortgage. Have loan number ready."
+  },
+  {
+    "slug": "wells-fargo-mortgage",
+    "name": "Wells Fargo Home Mortgage",
+    "phone": "1-800-357-6675",
+    "category_hints": ["mortgage-loan"],
+    "ivr_notes": "Press 2 for existing loan, then 3 for hardship."
+  },
+  {
+    "slug": "netflix",
+    "name": "Netflix Customer Service",
+    "phone": "1-866-579-7172",
+    "category_hints": ["subscription-cancel"],
+    "ivr_notes": "Cancellation is easier through web. Phone agents handle billing disputes."
+  },
+  {
+    "slug": "comcast-xfinity-mobile",
+    "name": "Xfinity Mobile Customer Service",
+    "phone": "1-888-936-4968",
+    "category_hints": ["cable-cancel"],
+    "ivr_notes": "Press 0 to reach agent."
+  },
+  {
+    "slug": "doordash-cs",
+    "name": "DoorDash Customer Service",
+    "phone": "1-855-973-1040",
+    "category_hints": ["other"],
+    "ivr_notes": "Order # required."
+  },
+  {
+    "slug": "uber-support",
+    "name": "Uber Critical Safety / Account Support",
+    "phone": "1-800-593-7069",
+    "category_hints": ["other"],
+    "ivr_notes": "For critical issues only — most support is in-app."
+  },
+  {
+    "slug": "geico",
+    "name": "Geico Customer Service",
+    "phone": "1-800-841-3000",
+    "category_hints": ["insurance-claim"],
+    "ivr_notes": "Have policy # ready. Press 2 for existing policy."
+  },
+  {
+    "slug": "state-farm",
+    "name": "State Farm Customer Service",
+    "phone": "1-800-782-8332",
+    "category_hints": ["insurance-claim"],
+    "ivr_notes": "Local agent routing typically faster than central."
+  },
+  {
+    "slug": "progressive",
+    "name": "Progressive Insurance",
+    "phone": "1-800-776-4737",
+    "category_hints": ["insurance-claim"],
+    "ivr_notes": "Press 2 for existing policy, then 1 for claim."
+  },
+  {
+    "slug": "allstate",
+    "name": "Allstate Customer Service",
+    "phone": "1-800-255-7828",
+    "category_hints": ["insurance-claim"],
+    "ivr_notes": "Press 0 for general agent."
+  },
+  {
+    "slug": "us-bank",
+    "name": "US Bank Customer Service",
+    "phone": "1-800-872-2657",
+    "category_hints": ["credit-card-increase", "mortgage-loan"],
+    "ivr_notes": "Press 0 to bypass account-number prompt."
+  },
+  {
+    "slug": "ally",
+    "name": "Ally Bank Customer Care",
+    "phone": "1-877-247-2559",
+    "category_hints": ["mortgage-loan"],
+    "ivr_notes": "Generally fast pickup. 24/7 support."
+  }
+]
diff --git a/lib/data.js b/lib/data.js
index 0aa6bd9..b30acaf 100644
--- a/lib/data.js
+++ b/lib/data.js
@@ -157,8 +157,25 @@ function updateStatus(id, status) {
   return all[idx];
 }
 
+// ── Preset business directory ─────────────────────────────────────────
+let _businesses = null;
+function loadBusinesses() {
+  if (_businesses) return _businesses;
+  try { _businesses = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'businesses.json'), 'utf8')); }
+  catch (e) { _businesses = []; }
+  return _businesses;
+}
+function businessBySlug(slug) {
+  return loadBusinesses().find(b => b.slug === slug) || null;
+}
+function businessesForCategory(catId) {
+  if (!catId) return loadBusinesses();
+  return loadBusinesses().filter(b => (b.category_hints || []).includes(catId));
+}
+
 module.exports = {
   CATEGORIES, categoryById,
   validatePhone, sanitizeRow,
   addCall, listCalls, getCall, updateStatus,
+  loadBusinesses, businessBySlug, businessesForCategory,
 };
diff --git a/lib/twilio.js b/lib/twilio.js
new file mode 100644
index 0000000..093b08d
--- /dev/null
+++ b/lib/twilio.js
@@ -0,0 +1,131 @@
+// Twilio Voice integration — dial, wait on hold, detect human, bridge to caller.
+//
+// PHASE 2 — DRY-RUN BY DEFAULT.
+//
+// Modes (env-controlled):
+//   - TWILIO_DRY_RUN=1  → log what we would do, advance status, never spend credits (DEFAULT)
+//   - TWILIO_DRY_RUN=0  → real outbound calls via Twilio
+//
+// Requires env when live:
+//   TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER, PUBLIC_URL
+//
+// Flow (live mode, planned):
+//   1. POST /v1/Accounts/{sid}/Calls.json
+//        - To: business_phone
+//        - From: TWILIO_PHONE_NUMBER
+//        - Url: ${PUBLIC_URL}/twilio/twiml/hold/${call_id}
+//        - StatusCallback: ${PUBLIC_URL}/twilio/status/${call_id}
+//        - MachineDetection: 'DetectMessageEnd'  (separate IVR from human)
+//        - AmdStatusCallback: ${PUBLIC_URL}/twilio/amd/${call_id}
+//   2. Hold TwiML: <Play loop="0"> a silence file + <Gather> with speech_timeout to listen for human
+//   3. On AmdStatusCallback with AnsweredBy=human → bridge:
+//        - REST: Modify the call's TwiML to <Dial><Number>callback_phone</Number></Dial>
+//        - Optional: <Say> a one-liner with the goal so the rep hears context
+//   4. On call.completed → update status to 'done' (or 'failed' if duration < threshold)
+//
+// The worker polls data/calls.json for status='queued' rows and dispatches.
+// 5-second tick. PM2 keeps it alive in prod.
+
+const fs = require('fs');
+const path = require('path');
+const data = require('./data');
+
+const DRY_RUN = process.env.TWILIO_DRY_RUN !== '0';  // default ON
+
+function log(...args) { console.log('[twilio]', new Date().toISOString(), ...args); }
+
+// Dry-run state machine — simulates a real call advancing through stages.
+// queued → dialing → on_hold → connected → done
+// Each transition takes a few seconds so the UI shows live progression.
+const DRY_RUN_STAGES = [
+  { from: 'queued',    to: 'dialing',   delay_ms: 2000 },
+  { from: 'dialing',   to: 'on_hold',   delay_ms: 4000 },
+  { from: 'on_hold',   to: 'connected', delay_ms: 12000 }, // simulated 12s hold
+  { from: 'connected', to: 'done',      delay_ms: 30000 }, // simulated 30s call
+];
+
+function pickNextStage(currentStatus) {
+  return DRY_RUN_STAGES.find(s => s.from === currentStatus);
+}
+
+async function dialCall(call) {
+  if (DRY_RUN) {
+    log(`DRY-RUN  call=${call.id} business="${call.business_name}" phone=${call.business_phone}`);
+    log(`         goal="${call.goal.slice(0, 80)}…"`);
+    log(`         callback=${call.callback_phone} (${call.callback_name})`);
+    data.updateStatus(call.id, 'dialing');
+    return { ok: true, dry_run: true };
+  }
+  // LIVE — would call Twilio REST API here
+  // const accountSid = process.env.TWILIO_ACCOUNT_SID;
+  // const authToken  = process.env.TWILIO_AUTH_TOKEN;
+  // const fromNum    = process.env.TWILIO_PHONE_NUMBER;
+  // const publicUrl  = process.env.PUBLIC_URL;
+  // const body = new URLSearchParams({
+  //   To: call.business_phone,
+  //   From: fromNum,
+  //   Url: `${publicUrl}/twilio/twiml/hold/${call.id}`,
+  //   StatusCallback: `${publicUrl}/twilio/status/${call.id}`,
+  //   MachineDetection: 'DetectMessageEnd',
+  //   AmdStatusCallback: `${publicUrl}/twilio/amd/${call.id}`,
+  //   AmdStatusCallbackMethod: 'POST',
+  // });
+  // const resp = await fetch(`https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Calls.json`, {
+  //   method: 'POST',
+  //   headers: {
+  //     'Authorization': 'Basic ' + Buffer.from(`${accountSid}:${authToken}`).toString('base64'),
+  //     'Content-Type': 'application/x-www-form-urlencoded',
+  //   },
+  //   body,
+  // });
+  // const json = await resp.json();
+  // if (!resp.ok) { log('LIVE dial failed:', json); return { ok: false, error: json }; }
+  // data.updateStatus(call.id, 'dialing');
+  // return { ok: true, sid: json.sid };
+  log('LIVE mode requested but not yet implemented — leaving call queued');
+  return { ok: false, error: 'live_mode_not_implemented' };
+}
+
+function advanceDryRun(call) {
+  const stage = pickNextStage(call.status);
+  if (!stage) return false;
+  const updatedAt = call.updated_at ? new Date(call.updated_at).getTime() : 0;
+  const age = Date.now() - updatedAt;
+  if (age < stage.delay_ms) return false;
+  log(`DRY-RUN  call=${call.id} ${stage.from} → ${stage.to}`);
+  data.updateStatus(call.id, stage.to);
+  return true;
+}
+
+let _tickRunning = false;
+async function tick() {
+  if (_tickRunning) return;
+  _tickRunning = true;
+  try {
+    const all = data.listCalls();  // listCalls returns the masked view, but status/id are unmasked
+    for (const c of all) {
+      // Get unmasked row for actual work
+      const full = data.getCall(c.id, true);
+      if (!full) continue;
+
+      if (full.status === 'queued') {
+        await dialCall(full);
+      } else if (DRY_RUN && ['dialing','on_hold','connected'].includes(full.status)) {
+        advanceDryRun(full);
+      }
+    }
+  } catch (e) {
+    log('tick error:', e.message);
+  } finally {
+    _tickRunning = false;
+  }
+}
+
+function start({ intervalMs = 5000 } = {}) {
+  log(`worker starting · DRY_RUN=${DRY_RUN} · interval=${intervalMs}ms`);
+  setInterval(tick, intervalMs);
+  // also tick once immediately
+  setTimeout(tick, 500);
+}
+
+module.exports = { start, tick, dialCall, DRY_RUN };
diff --git a/public/css/site.css b/public/css/site.css
index a2fec23..b94184c 100644
--- a/public/css/site.css
+++ b/public/css/site.css
@@ -294,3 +294,59 @@ html[data-theme='dark']  .theme-toggle-sun  { display: none; }
 .site-footer .wrap p { margin: .25rem 0; }
 .site-footer a { color: var(--text-muted); }
 .site-footer a:hover { color: var(--text); }
+
+/* Wizard progress bar */
+.wizard-progress { margin: 1rem 0 2rem; }
+.wizard-progress ol {
+  list-style: none; padding: 0; margin: 0 0 .5rem;
+  display: grid; grid-template-columns: repeat(4, 1fr); gap: .25rem;
+}
+.wizard-progress li {
+  display: flex; align-items: center; gap: .4rem;
+  padding: .35rem .55rem; font-size: .8rem;
+  color: var(--text-muted);
+  border-bottom: 2px solid var(--border);
+}
+.wizard-progress li.done    { color: var(--text); border-bottom-color: var(--accent); }
+.wizard-progress li.current { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
+.wizard-progress .wp-num {
+  display: inline-flex; align-items: center; justify-content: center;
+  width: 22px; height: 22px; border-radius: 50%;
+  background: var(--bg-alt); color: var(--text-muted);
+  font-size: .75rem; font-weight: 600; flex-shrink: 0;
+}
+.wizard-progress li.done    .wp-num { background: var(--accent); color: var(--accent-fg); }
+.wizard-progress li.current .wp-num { background: var(--accent); color: var(--accent-fg); }
+.wizard-progress-bar { height: 3px; background: var(--bg-alt); border-radius: 999px; overflow: hidden; }
+.wizard-progress-fill { height: 100%; background: var(--accent); transition: width .35s ease; }
+@media (max-width: 600px) {
+  .wizard-progress ol { grid-template-columns: 1fr 1fr; gap: .5rem; }
+}
+
+/* Business picker (step 2) */
+.business-pick {
+  display: flex; flex-direction: column; gap: .55rem; margin: .5rem 0;
+}
+.business-pick-card {
+  display: flex; align-items: flex-start; gap: .75rem;
+  padding: .85rem 1rem;
+  background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
+  cursor: pointer;
+  transition: border-color .15s, background .15s;
+}
+.business-pick-card:hover  { border-color: var(--accent); }
+.business-pick-card.selected { border-color: var(--accent); background: rgba(37,69,179,.06); }
+.business-pick-card input { margin-top: .3rem; flex-shrink: 0; }
+.business-pick-body { display: flex; flex-direction: column; gap: .15rem; flex: 1; min-width: 0; }
+.business-pick-body strong { font-family: var(--font-serif); font-weight: 500; font-size: 1rem; }
+.business-pick-ivr { font-style: italic; }
+.business-pick-custom { margin-top: .25rem; background: var(--surface); }
+.business-pick-custom span { font-weight: 500; }
+
+/* Wizard review card (step 4) */
+.review-card {
+  background: var(--bg-alt); border: 1px solid var(--border);
+  border-radius: var(--radius); padding: 1rem 1.25rem; margin: 1.25rem 0;
+}
+.review-card h3 { font-family: var(--font-serif); font-size: 1.1rem; margin: 0 0 .5rem; }
+.review-card .kv { grid-template-columns: max-content 1fr; gap: .25rem 1rem; }
diff --git a/public/js/wizard.js b/public/js/wizard.js
new file mode 100644
index 0000000..283c02a
--- /dev/null
+++ b/public/js/wizard.js
@@ -0,0 +1,28 @@
+// Wizard helpers — toggle "selected" state on business radio click,
+// auto-blank custom-name fields when a preset is picked.
+(function(){
+  // Business picker radios — visual feedback + auto-clear custom inputs
+  document.querySelectorAll('.business-pick-card').forEach(label => {
+    label.addEventListener('click', () => {
+      document.querySelectorAll('.business-pick-card.selected').forEach(el => el.classList.remove('selected'));
+      label.classList.add('selected');
+    });
+  });
+
+  // Category picker (step 1)
+  document.querySelectorAll('.category-pick-card').forEach(label => {
+    label.addEventListener('click', () => {
+      document.querySelectorAll('.category-pick-card.selected').forEach(el => el.classList.remove('selected'));
+      label.classList.add('selected');
+    });
+  });
+
+  // Phone formatting on blur
+  document.querySelectorAll('input[type=tel]').forEach(el => {
+    el.addEventListener('blur', () => {
+      const d = el.value.replace(/\D/g, '');
+      if (d.length === 10) el.value = `(${d.slice(0,3)}) ${d.slice(3,6)}-${d.slice(6)}`;
+      else if (d.length === 11 && d[0] === '1') el.value = `+1 (${d.slice(1,4)}) ${d.slice(4,7)}-${d.slice(7)}`;
+    });
+  });
+})();
diff --git a/routes/public.js b/routes/public.js
index 5f8e575..ad675b1 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -8,7 +8,6 @@ router.use((req, res, next) => {
   next();
 });
 
-// Home — hero + CTA
 router.get('/', (req, res) => {
   res.render('public/home', {
     title: 'HoldForMe — We wait on hold so you don\'t have to',
@@ -16,49 +15,153 @@ router.get('/', (req, res) => {
   });
 });
 
-// New-call form — full onboarding
+// ── 4-step wizard ─────────────────────────────────────────────────────
+// State carried via hidden inputs (no cookies, no session — works behind
+// any rate-limiter, survives back/forward). Each step renders the next.
+//
+// Step 1: /new                  — pick category
+// Step 2: /new/business         — pick from preset directory OR custom
+// Step 3: /new/details          — goal + account info
+// Step 4: /new/callback         — callback + limits + consent → submits
+
+function pickState(b) {
+  // Whitelist what carries between steps. Same fields as sanitizeRow but
+  // without the generated id/status — we accumulate during the wizard.
+  return {
+    category:           b.category || '',
+    business_slug:      b.business_slug || '',
+    business_name:      b.business_name || '',
+    business_phone:     b.business_phone || '',
+    goal:               b.goal || '',
+    notes:              b.notes || '',
+    account_number:     b.account_number || '',
+    last4_ssn:          b.last4_ssn || '',
+    billing_zip:        b.billing_zip || '',
+    date_of_birth:      b.date_of_birth || '',
+    auth_password:      b.auth_password || '',
+    callback_name:      b.callback_name || '',
+    callback_phone:     b.callback_phone || '',
+    callback_email:     b.callback_email || '',
+    best_time_window:   b.best_time_window || '',
+    max_hold_minutes:   b.max_hold_minutes || '60',
+    max_spend_cents:    b.max_spend_cents || '500',
+    consent_recording:  b.consent_recording === 'on' || b.consent_recording === true,
+    notify_sms:         b.notify_sms === 'on' || b.notify_sms === true || !b.notify_sms,  // default on
+    notify_email:       b.notify_email === 'on' || b.notify_email === true,
+    consent_terms:      b.consent_terms === 'on' || b.consent_terms === true,
+  };
+}
+
+// Step 1 — Category
 router.get('/new', (req, res) => {
-  res.render('public/new', {
-    title: 'Start a call — HoldForMe',
-    meta_desc: 'Tell us who to call and what to get done. We\'ll wait on hold, then connect you when an agent picks up.',
+  const state = pickState(req.query);
+  if (req.query.category) state.category = req.query.category;
+  res.render('public/wizard-1-category', {
+    title: 'Start a call — Step 1 of 4 — HoldForMe',
+    meta_desc: 'Step 1: pick the kind of call.',
     categories: data.CATEGORIES,
-    preset: data.categoryById(req.query.category) || null,
-    form: {},  // empty form
-    errors: [],
+    state, step: 1,
+  });
+});
+
+// Step 2 — Business (preset picker or custom)
+router.post('/new/business', (req, res) => {
+  const state = pickState(req.body);
+  if (!state.category) {
+    return res.redirect('/new');
+  }
+  const businesses = data.businessesForCategory(state.category);
+  res.render('public/wizard-2-business', {
+    title: 'Start a call — Step 2 of 4 — HoldForMe',
+    meta_desc: 'Step 2: who to call.',
+    categories: data.CATEGORIES,
+    businesses,
+    state, step: 2,
+    preset: data.categoryById(state.category),
+  });
+});
+
+// Step 3 — Details (goal + account info)
+router.post('/new/details', (req, res) => {
+  const state = pickState(req.body);
+  if (!state.category) return res.redirect('/new');
+
+  // If they picked a preset, hydrate name + phone from the directory
+  if (state.business_slug && state.business_slug !== '__custom__') {
+    const biz = data.businessBySlug(state.business_slug);
+    if (biz) {
+      state.business_name = biz.name;
+      state.business_phone = biz.phone;
+    }
+  }
+  // Validate at least one of preset-picked OR custom-filled
+  if (!state.business_name || !state.business_phone) {
+    const businesses = data.businessesForCategory(state.category);
+    return res.render('public/wizard-2-business', {
+      title: 'Start a call — Step 2 of 4 — HoldForMe',
+      meta_desc: 'Step 2: who to call.',
+      categories: data.CATEGORIES,
+      businesses,
+      state, step: 2,
+      preset: data.categoryById(state.category),
+      errors: ['Pick a preset or enter both a business name and phone number.'],
+    });
+  }
+
+  res.render('public/wizard-3-details', {
+    title: 'Start a call — Step 3 of 4 — HoldForMe',
+    meta_desc: 'Step 3: what you need done.',
+    state, step: 3,
+    preset: data.categoryById(state.category),
   });
 });
 
-// Submit a new call
-router.post('/new', (req, res) => {
+// Step 4 — Callback + limits + consent (final, then submits)
+router.post('/new/callback', (req, res) => {
+  const state = pickState(req.body);
+  if (!state.category) return res.redirect('/new');
+  if (!state.goal || state.goal.trim().length < 8) {
+    return res.render('public/wizard-3-details', {
+      title: 'Start a call — Step 3 of 4 — HoldForMe',
+      meta_desc: 'Step 3: what you need done.',
+      state, step: 3,
+      preset: data.categoryById(state.category),
+      errors: ['Goal is required (8+ chars). Be specific about what you want the rep to do.'],
+    });
+  }
+  res.render('public/wizard-4-callback', {
+    title: 'Start a call — Step 4 of 4 — HoldForMe',
+    meta_desc: 'Step 4: how to reach you back.',
+    state, step: 4,
+  });
+});
+
+// Final submit
+router.post('/new/submit', (req, res) => {
   const r = data.addCall(req.body || {});
   if (!r.ok) {
-    return res.status(400).render('public/new', {
-      title: 'Start a call — HoldForMe',
-      meta_desc: 'Tell us who to call and what to get done.',
-      categories: data.CATEGORIES,
-      preset: data.categoryById(req.body.category) || null,
-      form: req.body || {},
+    const state = pickState(req.body);
+    return res.status(400).render('public/wizard-4-callback', {
+      title: 'Start a call — Step 4 of 4 — HoldForMe',
+      meta_desc: 'Step 4: how to reach you back.',
+      state, step: 4,
       errors: r.errors,
     });
   }
   res.redirect('/calls/' + r.call.id);
 });
 
-// Your queue
 router.get('/calls', (req, res) => {
   res.render('public/calls', {
     title: 'Your call queue — HoldForMe',
-    meta_desc: 'Calls you\'ve queued up. Status, history, results.',
+    meta_desc: 'Calls you\'ve queued up.',
     calls: data.listCalls(),
   });
 });
 
-// Single call detail
 router.get('/calls/:id', (req, res) => {
   const id = req.params.id;
   if (!/^[A-Za-z0-9_-]{6,16}$/.test(id)) return res.status(404).render('public/404', { title: 'Not found — HoldForMe' });
-  // Reveal only if explicitly requested via ?reveal=1 — keeps sensitive fields
-  // hidden by default even on the detail page
   const c = data.getCall(id, req.query.reveal === '1');
   if (!c) return res.status(404).render('public/404', { title: 'Not found — HoldForMe' });
   res.render('public/call', {
@@ -69,7 +172,6 @@ router.get('/calls/:id', (req, res) => {
   });
 });
 
-// JSON API for the form's category-picker → field hints
 router.get('/api/category/:id', (req, res) => {
   const c = data.categoryById(req.params.id);
   if (!c) return res.status(404).json({ ok: false });
diff --git a/server.js b/server.js
index 65d86d5..8eeabc5 100644
--- a/server.js
+++ b/server.js
@@ -79,4 +79,14 @@ app.use((err, req, res, next) => {
 
 app.listen(PORT, () => {
   console.log(`holdforme listening on :${PORT} (${IS_PROD ? 'prod' : 'dev'})`);
+  // Start the call worker — DRY_RUN by default; set TWILIO_DRY_RUN=0 + Twilio creds for live.
+  // Skip if HFM_NO_WORKER=1 (useful for unit tests that don't want the background tick).
+  if (!process.env.HFM_NO_WORKER) {
+    try {
+      const twilio = require('./lib/twilio');
+      twilio.start({ intervalMs: 5000 });
+    } catch (e) {
+      console.error('[twilio] failed to start worker:', e.message);
+    }
+  }
 });
diff --git a/views/partials/wizard-progress.ejs b/views/partials/wizard-progress.ejs
new file mode 100644
index 0000000..f27377b
--- /dev/null
+++ b/views/partials/wizard-progress.ejs
@@ -0,0 +1,9 @@
+<nav class="wizard-progress" aria-label="Wizard progress">
+  <ol>
+    <li class="<%= step >= 1 ? 'done' : '' %><%= step === 1 ? ' current' : '' %>"><span class="wp-num">1</span> Category</li>
+    <li class="<%= step >= 2 ? 'done' : '' %><%= step === 2 ? ' current' : '' %>"><span class="wp-num">2</span> Business</li>
+    <li class="<%= step >= 3 ? 'done' : '' %><%= step === 3 ? ' current' : '' %>"><span class="wp-num">3</span> What &amp; account</li>
+    <li class="<%= step >= 4 ? 'done' : '' %><%= step === 4 ? ' current' : '' %>"><span class="wp-num">4</span> Callback</li>
+  </ol>
+  <div class="wizard-progress-bar"><div class="wizard-progress-fill" style="width: <%= ((step-1)/3)*100 %>%"></div></div>
+</nav>
diff --git a/views/partials/wizard-state.ejs b/views/partials/wizard-state.ejs
new file mode 100644
index 0000000..c4ed17f
--- /dev/null
+++ b/views/partials/wizard-state.ejs
@@ -0,0 +1,12 @@
+<%
+  // Carry every state field as a hidden input so step N+1 has everything from steps 1..N.
+  // Skip the field the current step is about to overwrite (passed in as `skip`).
+  var skipSet = (typeof skip !== 'undefined' && skip) ? new Set(skip) : new Set();
+  var allFields = ['category','business_slug','business_name','business_phone','goal','notes','account_number','last4_ssn','billing_zip','date_of_birth','auth_password','callback_name','callback_phone','callback_email','best_time_window','max_hold_minutes','max_spend_cents'];
+%>
+<% allFields.forEach(function(f) { if (!skipSet.has(f) && state[f]) { %>
+<input type="hidden" name="<%= f %>" value="<%= state[f] %>">
+<% } }); %>
+<% ['consent_recording','notify_sms','notify_email','consent_terms'].forEach(function(f) { if (!skipSet.has(f) && state[f]) { %>
+<input type="hidden" name="<%= f %>" value="on">
+<% } }); %>
diff --git a/views/public/wizard-1-category.ejs b/views/public/wizard-1-category.ejs
new file mode 100644
index 0000000..636441c
--- /dev/null
+++ b/views/public/wizard-1-category.ejs
@@ -0,0 +1,32 @@
+<%- include('../partials/head', { title, meta_desc }) %>
+<%- include('../partials/header') %>
+
+<main class="wizard">
+  <section class="section">
+    <div class="wrap narrow">
+      <p class="muted small"><a href="/">← Home</a></p>
+      <%- include('../partials/wizard-progress', { step }) %>
+      <h1>What kind of call?</h1>
+      <p class="muted">Pick the closest fit. We'll preset the right fields for the next steps.</p>
+
+      <form method="POST" action="/new/business" class="wizard-form" autocomplete="off">
+        <div class="category-pick">
+          <% categories.forEach(function(c) { var selected = state.category === c.id; %>
+            <label class="category-pick-card <%= selected ? 'selected' : '' %>">
+              <input type="radio" name="category" value="<%= c.id %>" <%= selected ? 'checked' : '' %> required>
+              <span class="category-pick-icon"><%= c.icon %></span>
+              <span class="category-pick-label"><%= c.label %></span>
+            </label>
+          <% }); %>
+        </div>
+
+        <div class="form-actions">
+          <button type="submit" class="btn primary">Next: who to call →</button>
+          <a class="btn ghost" href="/">Cancel</a>
+        </div>
+      </form>
+    </div>
+  </section>
+</main>
+
+<%- include('../partials/footer') %>
diff --git a/views/public/wizard-2-business.ejs b/views/public/wizard-2-business.ejs
new file mode 100644
index 0000000..6dc3bcf
--- /dev/null
+++ b/views/public/wizard-2-business.ejs
@@ -0,0 +1,64 @@
+<%- include('../partials/head', { title, meta_desc }) %>
+<%- include('../partials/header') %>
+
+<main class="wizard">
+  <section class="section">
+    <div class="wrap narrow">
+      <p class="muted small"><a href="/new">← Back</a></p>
+      <%- include('../partials/wizard-progress', { step }) %>
+
+      <h1>Who do we call?</h1>
+      <p class="muted">Pick from the directory below, or scroll down to enter a custom business.</p>
+
+      <% if (typeof errors !== 'undefined' && errors && errors.length) { %>
+        <div class="form-errors"><strong>Fix:</strong><ul><% errors.forEach(function(e) { %><li><%= e %></li><% }); %></ul></div>
+      <% } %>
+
+      <form method="POST" action="/new/details" class="wizard-form" autocomplete="off">
+        <%- include('../partials/wizard-state', { state, skip: ['business_slug', 'business_name', 'business_phone'] }) %>
+
+        <% if (businesses && businesses.length) { %>
+          <fieldset>
+            <legend>Pick from the directory <span class="muted small">(<%= businesses.length %> preset business<%= businesses.length === 1 ? '' : 'es' %> for <%= preset && preset.label %>)</span></legend>
+            <div class="business-pick">
+              <% businesses.forEach(function(b) { var selected = state.business_slug === b.slug; %>
+                <label class="business-pick-card <%= selected ? 'selected' : '' %>">
+                  <input type="radio" name="business_slug" value="<%= b.slug %>" <%= selected ? 'checked' : '' %>>
+                  <div class="business-pick-body">
+                    <strong><%= b.name %></strong>
+                    <span class="muted small"><%= b.phone %></span>
+                    <% if (b.ivr_notes) { %><span class="muted small business-pick-ivr">IVR: <%= b.ivr_notes %></span><% } %>
+                  </div>
+                </label>
+              <% }); %>
+            </div>
+          </fieldset>
+        <% } %>
+
+        <fieldset>
+          <legend>Or enter a custom business</legend>
+          <label class="business-pick-card business-pick-custom <%= state.business_slug === '__custom__' ? 'selected' : '' %>">
+            <input type="radio" name="business_slug" value="__custom__" <%= state.business_slug === '__custom__' ? 'checked' : '' %>>
+            <span>Custom — fill in below</span>
+          </label>
+          <label class="field">
+            <span class="field-label">Business name</span>
+            <input type="text" name="business_name" value="<%= state.business_name || '' %>" maxlength="120" placeholder="e.g. My local clinic billing dept">
+          </label>
+          <label class="field">
+            <span class="field-label">Phone number to call</span>
+            <input type="tel" name="business_phone" value="<%= state.business_phone || '' %>" maxlength="20" placeholder="1-800-...">
+          </label>
+        </fieldset>
+
+        <div class="form-actions">
+          <button type="submit" class="btn primary">Next: what you want done →</button>
+          <button type="submit" formmethod="POST" formaction="/new" formnovalidate class="btn ghost">← Back</button>
+        </div>
+      </form>
+    </div>
+  </section>
+</main>
+
+<%- include('../partials/footer') %>
+<script src="/js/wizard.js" defer></script>
diff --git a/views/public/wizard-3-details.ejs b/views/public/wizard-3-details.ejs
new file mode 100644
index 0000000..3a908c6
--- /dev/null
+++ b/views/public/wizard-3-details.ejs
@@ -0,0 +1,71 @@
+<%- include('../partials/head', { title, meta_desc }) %>
+<%- include('../partials/header') %>
+
+<main class="wizard">
+  <section class="section">
+    <div class="wrap narrow">
+      <p class="muted small"><a href="#" onclick="history.back();return false">← Back</a></p>
+      <%- include('../partials/wizard-progress', { step }) %>
+
+      <h1>What do you want done?</h1>
+      <p class="muted">
+        Calling <strong><%= state.business_name %></strong> at <%= state.business_phone %>. Be specific about the outcome.
+      </p>
+
+      <% if (typeof errors !== 'undefined' && errors && errors.length) { %>
+        <div class="form-errors"><strong>Fix:</strong><ul><% errors.forEach(function(e) { %><li><%= e %></li><% }); %></ul></div>
+      <% } %>
+
+      <form method="POST" action="/new/callback" class="wizard-form" autocomplete="off">
+        <%- include('../partials/wizard-state', { state, skip: ['goal','notes','account_number','last4_ssn','billing_zip','date_of_birth','auth_password'] }) %>
+
+        <fieldset>
+          <legend>The goal of the call</legend>
+          <label class="field">
+            <span class="field-label">What should the rep do? <em>required, 8+ chars</em></span>
+            <textarea name="goal" rows="5" maxlength="1500" placeholder="<%= preset && preset.defaultGoal ? preset.defaultGoal : 'Be specific. \"Request credit limit increase from $5,000 to $10,000\" is better than \"help with credit card.\"' %>" required><%= state.goal || (preset && preset.defaultGoal) || '' %></textarea>
+          </label>
+          <label class="field">
+            <span class="field-label">Notes the rep should know <span class="muted">— optional</span></span>
+            <textarea name="notes" rows="3" maxlength="500" placeholder="Context, history, prior case numbers..."><%= state.notes || '' %></textarea>
+          </label>
+        </fieldset>
+
+        <fieldset>
+          <legend>Account info <span class="muted">(only if the rep will ask)</span></legend>
+          <p class="muted small">Masked in queue views, only revealed on the call's own detail page when you click Reveal.</p>
+          <div class="field-row">
+            <label class="field">
+              <span class="field-label">Account number</span>
+              <input type="text" name="account_number" value="<%= state.account_number || '' %>" maxlength="40" placeholder="Last 4 minimum, full if needed" autocomplete="off">
+            </label>
+            <label class="field">
+              <span class="field-label">Last 4 of SSN</span>
+              <input type="text" name="last4_ssn" value="<%= state.last4_ssn || '' %>" maxlength="4" inputmode="numeric" pattern="\d{4}" placeholder="1234" autocomplete="off">
+            </label>
+          </div>
+          <div class="field-row">
+            <label class="field">
+              <span class="field-label">Billing ZIP</span>
+              <input type="text" name="billing_zip" value="<%= state.billing_zip || '' %>" maxlength="5" inputmode="numeric" pattern="\d{5}" placeholder="90210" autocomplete="off">
+            </label>
+            <label class="field">
+              <span class="field-label">Date of birth</span>
+              <input type="text" name="date_of_birth" value="<%= state.date_of_birth || '' %>" maxlength="10" placeholder="MM/DD/YYYY" autocomplete="off">
+            </label>
+          </div>
+          <label class="field">
+            <span class="field-label">Verbal-auth password / security answer</span>
+            <input type="text" name="auth_password" value="<%= state.auth_password || '' %>" maxlength="60" placeholder='"Maiden name: Smith"' autocomplete="off">
+          </label>
+        </fieldset>
+
+        <div class="form-actions">
+          <button type="submit" class="btn primary">Next: how to reach you →</button>
+        </div>
+      </form>
+    </div>
+  </section>
+</main>
+
+<%- include('../partials/footer') %>
diff --git a/views/public/wizard-4-callback.ejs b/views/public/wizard-4-callback.ejs
new file mode 100644
index 0000000..e4b1b62
--- /dev/null
+++ b/views/public/wizard-4-callback.ejs
@@ -0,0 +1,93 @@
+<%- include('../partials/head', { title, meta_desc }) %>
+<%- include('../partials/header') %>
+
+<main class="wizard">
+  <section class="section">
+    <div class="wrap narrow">
+      <p class="muted small"><a href="#" onclick="history.back();return false">← Back</a></p>
+      <%- include('../partials/wizard-progress', { step }) %>
+
+      <h1>How do we reach you?</h1>
+      <p class="muted">When an agent answers, we'll text and call this number so you can jump in fresh.</p>
+
+      <% if (typeof errors !== 'undefined' && errors && errors.length) { %>
+        <div class="form-errors"><strong>Fix:</strong><ul><% errors.forEach(function(e) { %><li><%= e %></li><% }); %></ul></div>
+      <% } %>
+
+      <form method="POST" action="/new/submit" class="wizard-form" autocomplete="off">
+        <%- include('../partials/wizard-state', { state, skip: ['callback_name','callback_phone','callback_email','best_time_window','max_hold_minutes','max_spend_cents','consent_recording','notify_sms','notify_email','consent_terms'] }) %>
+
+        <fieldset>
+          <legend>Your callback</legend>
+          <div class="field-row">
+            <label class="field">
+              <span class="field-label">Your name <em>required</em></span>
+              <input type="text" name="callback_name" value="<%= state.callback_name || '' %>" maxlength="80" required>
+            </label>
+            <label class="field">
+              <span class="field-label">Your phone <em>required</em></span>
+              <input type="tel" name="callback_phone" value="<%= state.callback_phone || '' %>" maxlength="20" placeholder="we bridge here on pickup" required>
+            </label>
+          </div>
+          <label class="field">
+            <span class="field-label">Your email <span class="muted">— optional</span></span>
+            <input type="email" name="callback_email" value="<%= state.callback_email || '' %>" maxlength="120">
+          </label>
+          <label class="field">
+            <span class="field-label">Best time window <span class="muted">— optional</span></span>
+            <input type="text" name="best_time_window" value="<%= state.best_time_window || '' %>" maxlength="60" placeholder='e.g. "weekdays 9am-6pm PT"'>
+          </label>
+        </fieldset>
+
+        <fieldset>
+          <legend>Limits</legend>
+          <div class="field-row">
+            <label class="field">
+              <span class="field-label">Max hold time</span>
+              <select name="max_hold_minutes">
+                <option value="15"  <%= state.max_hold_minutes === '15'  ? 'selected' : '' %>>15 min — quick try</option>
+                <option value="30"  <%= state.max_hold_minutes === '30'  ? 'selected' : '' %>>30 min</option>
+                <option value="60"  <%= !state.max_hold_minutes || state.max_hold_minutes === '60' ? 'selected' : '' %>>60 min (default)</option>
+                <option value="120" <%= state.max_hold_minutes === '120' ? 'selected' : '' %>>2 hours</option>
+                <option value="180" <%= state.max_hold_minutes === '180' ? 'selected' : '' %>>3 hours (insurance / IRS worst-case)</option>
+              </select>
+            </label>
+            <label class="field">
+              <span class="field-label">Max spend</span>
+              <select name="max_spend_cents">
+                <option value="100"  <%= state.max_spend_cents === '100'  ? 'selected' : '' %>>$1.00</option>
+                <option value="250"  <%= state.max_spend_cents === '250'  ? 'selected' : '' %>>$2.50</option>
+                <option value="500"  <%= !state.max_spend_cents || state.max_spend_cents === '500' ? 'selected' : '' %>>$5.00 (default)</option>
+                <option value="1000" <%= state.max_spend_cents === '1000' ? 'selected' : '' %>>$10.00</option>
+                <option value="2500" <%= state.max_spend_cents === '2500' ? 'selected' : '' %>>$25.00</option>
+              </select>
+            </label>
+          </div>
+        </fieldset>
+
+        <fieldset>
+          <legend>Notifications &amp; consent</legend>
+          <label class="check"><input type="checkbox" name="notify_sms"   <%= state.notify_sms ? 'checked' : 'checked' %>> Text me when an agent picks up</label>
+          <label class="check"><input type="checkbox" name="notify_email" <%= state.notify_email ? 'checked' : '' %>>          Email me the call summary afterwards</label>
+          <label class="check"><input type="checkbox" name="consent_recording" <%= state.consent_recording ? 'checked' : '' %>> Record the call (two-party-consent states get an in-call disclosure)</label>
+          <label class="check req"><input type="checkbox" name="consent_terms" <%= state.consent_terms ? 'checked' : '' %> required> I authorize HoldForMe to dial the business on my behalf and bridge me when a human picks up <em>required</em></label>
+        </fieldset>
+
+        <div class="review-card">
+          <h3>Quick review</h3>
+          <dl class="kv">
+            <dt>Calling</dt><dd><strong><%= state.business_name %></strong> · <%= state.business_phone %></dd>
+            <dt>For</dt><dd><%= state.goal && state.goal.length > 140 ? state.goal.slice(0,140)+'…' : state.goal %></dd>
+            <% if (state.account_number) { %><dt>Account</dt><dd>•••••• provided</dd><% } %>
+          </dl>
+        </div>
+
+        <div class="form-actions">
+          <button type="submit" class="btn primary">Queue this call →</button>
+        </div>
+      </form>
+    </div>
+  </section>
+</main>
+
+<%- include('../partials/footer') %>

← af3a901 initial scaffold — HoldForMe phase-1 onboarding MVP. Capture  ·  back to Butlr  ·  perfect: (1) AES-256-GCM encryption at rest for account_numb 282926f →